diff --git a/.claude/rules/sim-content-delivery.md b/.claude/rules/sim-content-delivery.md index 6d976d8471d..5010c8e8ace 100644 --- a/.claude/rules/sim-content-delivery.md +++ b/.claude/rules/sim-content-delivery.md @@ -77,7 +77,7 @@ own unless noted. | Path | U | E | R / C | |---|---|---|---| -| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Redacted | Delivered | +| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Opaque: into a chat workbench with entries imported, so output is redacted; refused without one | Delivered | | File download (run_code proxy) | Refused before bytes enter workbench history | Recorded in workbench history | Delivered | | Requested mounts | Refused before code executes, listed in `excludedInputs`; later calls remain usable | Recorded in workbench history | Delivered | | File text, version text, `files read`, grep | 403 | Redacted (grep redacts before clipping) | Delivered | diff --git a/.cursor/rules/sim-content-delivery.mdc b/.cursor/rules/sim-content-delivery.mdc index 4e1b90df027..f253bf1f54d 100644 --- a/.cursor/rules/sim-content-delivery.mdc +++ b/.cursor/rules/sim-content-delivery.mdc @@ -68,7 +68,7 @@ own unless noted. | Path | U | E | R / C | |---|---|---|---| -| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Redacted | Delivered | +| File download (CLI `files get`) | Delivered once; invocation withheld from the model; workbench copy stays unknown | Opaque: into a chat workbench with entries imported, so output is redacted; refused without one | Delivered | | File download (run_code proxy) | Refused before bytes enter workbench history | Recorded in workbench history | Delivered | | Requested mounts | Refused before code executes, listed in `excludedInputs`; later calls remain usable | Recorded in workbench history | Delivered | | File text, version text, `files read`, grep | 403 | Redacted (grep redacts before clipping) | Delivered | diff --git a/.github/scripts/promote-app-image.sh b/.github/scripts/promote-app-image.sh index 39cb88f23ff..86cb8a6d77d 100644 --- a/.github/scripts/promote-app-image.sh +++ b/.github/scripts/promote-app-image.sh @@ -5,11 +5,12 @@ REGISTRY="${1:?registry required}" REPOSITORY="${2:?repository required}" SOURCE_TAG="${3:?source tag required}" DEPLOY_TAG="${4:?deploy tag required}" +RELEASE_TAG="${5:-}" : "${GITHUB_OUTPUT:?GitHub output file required}" SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) PREVIOUS=$(bash "$SCRIPT_DIR/get-ecr-image-digest.sh" "$REPOSITORY" "$DEPLOY_TAG" --allow-missing) EPOCH=$(date +%s) -docker buildx imagetools create -t "$REGISTRY/$REPOSITORY:$DEPLOY_TAG" "$REGISTRY/$REPOSITORY:$SOURCE_TAG" +bash "$SCRIPT_DIR/promote-image.sh" "$REGISTRY" "$REPOSITORY" "$SOURCE_TAG" "$DEPLOY_TAG" "$RELEASE_TAG" DIGEST=$(bash "$SCRIPT_DIR/get-ecr-image-digest.sh" "$REPOSITORY" "$DEPLOY_TAG") CHANGED=true if [ "$DIGEST" = "$PREVIOUS" ]; then CHANGED=false; fi diff --git a/.github/scripts/promote-image.sh b/.github/scripts/promote-image.sh new file mode 100755 index 00000000000..b3fc6525bce --- /dev/null +++ b/.github/scripts/promote-image.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Point a deploy tag (and, for production, its release- alias) at a built image. +# One imagetools call writes both tags, so they always share one list digest. +set -euo pipefail +REGISTRY="${1:?registry required}" +REPOSITORY="${2:?repository required}" +SOURCE_TAG="${3:?source tag required}" +DEPLOY_TAG="${4:?deploy tag required}" +RELEASE_TAG="${5:-}" +TAG_ARGS=(-t "$REGISTRY/$REPOSITORY:$DEPLOY_TAG") +if [ -n "$RELEASE_TAG" ]; then TAG_ARGS+=(-t "$REGISTRY/$REPOSITORY:$RELEASE_TAG"); fi +docker buildx imagetools create "${TAG_ARGS[@]}" "$REGISTRY/$REPOSITORY:$SOURCE_TAG" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index fce5f4d359c..6bd415230ae 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -243,6 +243,17 @@ jobs: working-directory: packages/db run: bun run db:migrate + # JOBS=4 caps node-gyp parallelism as docker/app.Dockerfile does (laverdet/isolated-vm#428). + - name: Build native isolate addon for Project file rendering + if: matrix.group == 'project-files' + working-directory: apps/sim + env: + JOBS: 4 + run: | + addon_dir="$(node -p 'require("node:path").dirname(require.resolve("isolated-vm/package.json", { paths: ["."] }))')" + cd "$addon_dir" + bun run install + # No step timeout: the job's bound covers a hang without cutting a slow but healthy suite # short of writing its report. - name: Run end-to-end suites diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5de7794781..6482b395ea2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -520,8 +520,11 @@ jobs: run: | REGISTRY="${{ steps.login-ecr.outputs.registry }}" + RELEASE_TAG="" if [ "${{ github.ref }}" = "refs/heads/main" ]; then ECR_TAG="latest" + # ECR lifecycle retains release- aliases as prod rollback targets. + RELEASE_TAG="release-${SOURCE_TAG}" elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then ECR_TAG="dev" else @@ -540,13 +543,11 @@ jobs: # Move the app last so a preceding tag failure cannot start app rollout # and then skip the downstream Trigger promotion job. for repo in $ECR_REPOS; do - echo "πŸš€ Promoting ${repo}:${SOURCE_TAG} to ${ECR_TAG}" + echo "πŸš€ Promoting ${repo}:${SOURCE_TAG} to ${ECR_TAG}${RELEASE_TAG:+ and ${RELEASE_TAG}}" if [ "$repo" = "$APP_REPO" ]; then - bash .github/scripts/promote-app-image.sh "$REGISTRY" "$APP_REPO" "$SOURCE_TAG" "$ECR_TAG" + bash .github/scripts/promote-app-image.sh "$REGISTRY" "$APP_REPO" "$SOURCE_TAG" "$ECR_TAG" "$RELEASE_TAG" else - docker buildx imagetools create \ - -t "${REGISTRY}/${repo}:${ECR_TAG}" \ - "${REGISTRY}/${repo}:${SOURCE_TAG}" + bash .github/scripts/promote-image.sh "$REGISTRY" "$repo" "$SOURCE_TAG" "$ECR_TAG" "$RELEASE_TAG" fi done diff --git a/apps/desktop/e2e/chat-panel.spec.ts b/apps/desktop/e2e/chat-panel.spec.ts index 69bc391dde7..446f791db24 100644 --- a/apps/desktop/e2e/chat-panel.spec.ts +++ b/apps/desktop/e2e/chat-panel.spec.ts @@ -113,6 +113,9 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c }) const shell = app const page = await shell.firstWindow() + const inputSession = await page.context().newCDPSession(page) + // Playwright otherwise keeps the document focused when its native window loses focus. + await inputSession.send('Emulation.setFocusEmulationEnabled', { enabled: false }) page.on('pageerror', (error) => errors.push(error.message)) page.on('crash', () => { rendererCrashed = true @@ -253,41 +256,102 @@ test('chat panel sizes survive navigation, chat switches, collapse, and layout c 'detach', 'chat switch', ] as const) { - await check(`${interruption} keeps the previous saved width`, async () => { + const keepsDraggedWidth = interruption === 'capture loss' || interruption === 'blur' + await check( + `${interruption} keeps the ${keepsDraggedWidth ? 'dragged' : 'previous saved'} width`, + async () => { + const before = await page.evaluate(() => JSON.stringify(localStorage)) + const rect = await beginDrag() + await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) + await expectWidth(598) + if (interruption === 'pointercancel') { + await divider.dispatchEvent('pointercancel', { pointerId: 1 }) + } else if (interruption === 'capture loss') { + await divider.evaluate((element) => element.releasePointerCapture(1)) + await page.mouse.move(rect.x + 100, rect.y + 100) + } else if (interruption === 'blur') { + await shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].blur()) + await expect.poll(() => page.evaluate(() => document.hasFocus())).toBe(false) + } else if (interruption === 'chat switch') { + await page + .getByRole('button', { name: 'organization-chat-a', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expectWidth(560) + } else { + await page + .getByRole('button', { name: 'Settings', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expect(panel).toHaveCount(0) + } + if (keepsDraggedWidth) { + await expectWidth(598) + expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before) + if (interruption === 'blur') { + await shell.evaluate(({ app, BrowserWindow }) => { + app.focus({ steal: true }) + BrowserWindow.getAllWindows()[0].focus() + }) + } + await page.mouse.move(rect.x + 150, rect.y + 100) + await expectWidth(598) + } + await page.mouse.up() + if (interruption === 'chat switch') { + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + } + if (interruption === 'detach') { + await page.getByRole('button', { name: 'Back', exact: true }).click() + } + await expectWidth(keepsDraggedWidth ? 598 : 698) + if (keepsDraggedWidth) { + await page.reload() + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(598) + await dragTo(698) + } else { + expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) + } + } + ) + } + + for (const button of ['middle', 'right'] as const) { + await check(`${button}-button drags leave the panel width unchanged`, async () => { const before = await page.evaluate(() => JSON.stringify(localStorage)) - const rect = await beginDrag() + await divider.hover({ position: { x: 4, y: 100 } }) + const rect = await panel.boundingBox() + if (!rect) throw new Error('Missing panel bounds') + await page.mouse.down({ button }) await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) - await expectWidth(598) - if (interruption === 'pointercancel') { - await divider.dispatchEvent('pointercancel', { pointerId: 1 }) - } else if (interruption === 'capture loss') { - await divider.evaluate((element) => element.releasePointerCapture(1)) - await page.mouse.move(rect.x + 101, rect.y + 100) - } else if (interruption === 'blur') { - await page.evaluate(() => window.dispatchEvent(new Event('blur'))) - } else if (interruption === 'chat switch') { - await page - .getByRole('button', { name: 'organization-chat-a', exact: true }) - .evaluate((element: HTMLButtonElement) => element.click()) - await expectWidth(560) - } else { - await page - .getByRole('button', { name: 'Settings', exact: true }) - .evaluate((element: HTMLButtonElement) => element.click()) - await expect(panel).toHaveCount(0) - } - await page.mouse.up() - if (interruption === 'chat switch') { - await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() - } - if (interruption === 'detach') { - await page.getByRole('button', { name: 'Back', exact: true }).click() - } + await page.mouse.up({ button }) await expectWidth(698) expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) }) } + await check('a touch drag resizes the panel without becoming a scroll gesture', async () => { + const rect = await panel.boundingBox() + if (!rect) throw new Error('Missing panel bounds') + await inputSession.send('Emulation.setTouchEmulationEnabled', { enabled: true }) + await inputSession.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x: rect.x, y: rect.y + 100 }], + }) + for (let offset = 10; offset <= 100; offset += 10) { + await inputSession.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x: rect.x + offset, y: rect.y + 100 }], + }) + } + await inputSession.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await inputSession.send('Emulation.setTouchEmulationEnabled', { enabled: false }) + await expectWidth(598) + await page.reload() + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(598) + await dragTo(698) + }) + await check('a viewport change during a drag clamps the committed display width', async () => { await expectWidth(698) const rect = await beginDrag() diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index 315ec4600c8..597b8fbb2d8 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -802,6 +802,27 @@ sim files mkdir API request and response schemas: [createFileFolder](/api-reference/files/createFilesFolder). +## Other commands + +### Copy file items + +OAuth login or personal API key required. + +```bash +sim files copy [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--source ` | Yes | Source owner and selected fileIds or folderIds (JSON, or @path / @- to read a file or stdin). | +| `--destination ` | Yes | Destination owner and optional folderId (JSON, or @path / @- to read a file or stdin). | + + + ## Related docs - [Files](/files) diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 27318d9e39e..de34d2524d6 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -988,6 +988,25 @@ sim files batch-delete [options] API request and response schemas: [bulkDeleteFiles](/api-reference/files/bulkDeleteFiles). +### sim files copy + +Copy file items (OAuth login or personal API key required) + +```bash +sim files copy [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--source ` | Yes | Source owner and selected fileIds or folderIds (JSON, or @path / @- to read a file or stdin). | +| `--destination ` | Yes | Destination owner and optional folderId (JSON, or @path / @- to read a file or stdin). | + + + ### sim files create Create file diff --git a/apps/docs/lib/llms.ts b/apps/docs/lib/llms.ts index 0a6fc354377..831d4e22400 100644 --- a/apps/docs/lib/llms.ts +++ b/apps/docs/lib/llms.ts @@ -4,32 +4,6 @@ import { getIntegrationLandingPage, INTEGRATION_LANDING_LABEL } from '@/lib/inte import { getApiSpecContent } from '@/lib/openapi' import type { PageData, source } from '@/lib/source' -const TABLE_ROW = /^\|.*\|$/ -const TABLE_DELIMITER_CELL = /^\s*(:?)-+(:?)\s*$/ -const UNESCAPED_PIPE = /(? { - if (line.startsWith('```')) inFence = !inFence - if (inFence || !TABLE_ROW.test(line)) return line - const cells = line.slice(1, -1).split(UNESCAPED_PIPE) - if (cells.every((cell) => TABLE_DELIMITER_CELL.test(cell))) { - return `|${cells.map((cell) => cell.replace(TABLE_DELIMITER_CELL, ' $1---$2 ')).join('|')}|` - } - return `|${cells.map((cell) => ` ${cell.replace(/^ /, '').trimEnd()} `).join('|')}|` - }) - .join('\n') -} - type LLMPageData = PageData & { getAPIPageProps?: () => ApiPageProps } /** @@ -62,5 +36,5 @@ export async function getLLMText(page: InferPageType) { : '' return `${heading} -${landingLink}${compactTables(processed)}` +${landingLink}${processed}` } diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index aecb8280293..0a4442d932b 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process' import path from 'node:path' +import { remarkGfm } from 'fumadocs-core/mdx-plugins' import { defineConfig, defineDocs, frontmatterSchema } from 'fumadocs-mdx/config' import lastModified from 'fumadocs-mdx/plugins/last-modified' import { curlJsonBodyGrammar } from './lib/shiki-curl-json' @@ -66,6 +67,22 @@ export default defineConfig({ lastModified({ versionControl: async (file) => gitLastModified?.get(path.resolve(file)) }), ], mdxOptions: { + /** + * The processed markdown behind `.md` pages, `llms-full.txt`, and Copy page is serialized by + * remark-gfm, which by default pads every table cell to its column's widest cell β€” one long + * enum pads a whole table. `tablePipeAlign: false` keeps the table and drops the padding; it + * only affects serializing to markdown, never the rendered page. + */ + remarkPlugins: (plugins) => { + if (!plugins.includes(remarkGfm)) { + throw new Error( + 'The fumadocs-mdx preset no longer registers remarkGfm; reapply tablePipeAlign' + ) + } + return plugins.map((plugin) => + plugin === remarkGfm ? [remarkGfm, { tablePipeAlign: false }] : plugin + ) + }, /** * Shiki defaults to `github-light` / `github-dark`, whose blues and purples appear nowhere * in the product. These themes carry the platform's own token colors instead β€” see diff --git a/apps/pii/server.py b/apps/pii/server.py index 1cba05178b6..e0410c7b1b4 100644 --- a/apps/pii/server.py +++ b/apps/pii/server.py @@ -9,6 +9,8 @@ import json import logging import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from typing import Any import regex as regex_module @@ -174,10 +176,14 @@ def build_analyzer() -> AnalyzerEngine: logger.addHandler(_log_handler) logger.propagate = False -logger.info("building analyzer (spacy)") -analyzer = build_analyzer() -batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer) -anonymizer = AnonymizerEngine() +# Built per worker by `lifespan`, never at import: with --workers N the uvicorn +# supervisor imports this module too, and a module-level build left it holding an +# idle copy of every model. +analyzer: AnalyzerEngine +batch_analyzer: BatchAnalyzerEngine +anonymizer: AnonymizerEngine +NER_ENTITIES: frozenset +_BLANK_ARTIFACTS: dict # Every entity the spaCy NER recognizers can actually produce. A request touching # any of these must run spaCy; a request naming only non-NER (regex/checksum) @@ -207,30 +213,6 @@ def _producible_ner_entities() -> frozenset: return frozenset(producible) -NER_ENTITIES = _SPACY_NER_FLOOR | ( - frozenset( - entity - for recognizer in analyzer.registry.recognizers - if isinstance(recognizer, SpacyRecognizer) - for entity in recognizer.supported_entities - ) - & _producible_ner_entities() -) - -# One blank NlpArtifacts per language, built once at startup. Passing these to -# analyze() skips nlp_engine.process_text (the spaCy tok2vec+ner pass) entirely: -# the pattern recognizers still match on the raw text, SpacyRecognizer finds -# nothing in blank artifacts (and can only be consulted at all for claimed-but- -# unproducible entities like PHONE_NUMBER), and score_threshold is unset so -# detection is identical. -# Only context-based score boosting (which needs real tokens) is unavailable β€” an -# accepted trade for skipping NER on the hot block-output path. Read-only, so it -# is safe to share across requests and workers. -_BLANK_ARTIFACTS = { - language: analyzer.nlp_engine.process_text("", language) - for language in SUPPORTED_LANGUAGES -} - def _regex_only(entities: list[str] | None, score_threshold: float | None) -> bool: """True when the spaCy NLP pass can be skipped: the request names entities, none @@ -308,7 +290,39 @@ def _analyze_many( ) -app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None) +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + global analyzer, batch_analyzer, anonymizer, NER_ENTITIES, _BLANK_ARTIFACTS + logger.info("building analyzer (spacy)") + analyzer = build_analyzer() + batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer) + anonymizer = AnonymizerEngine() + NER_ENTITIES = _SPACY_NER_FLOOR | ( + frozenset( + entity + for recognizer in analyzer.registry.recognizers + if isinstance(recognizer, SpacyRecognizer) + for entity in recognizer.supported_entities + ) + & _producible_ner_entities() + ) + # One blank NlpArtifacts per language, built once at startup. Passing these to + # analyze() skips nlp_engine.process_text (the spaCy tok2vec+ner pass) entirely: + # the pattern recognizers still match on the raw text, SpacyRecognizer finds + # nothing in blank artifacts (and can only be consulted at all for claimed-but- + # unproducible entities like PHONE_NUMBER), and score_threshold is unset so + # detection is identical. + # Only context-based score boosting (which needs real tokens) is unavailable β€” an + # accepted trade for skipping NER on the hot block-output path. Read-only, so it + # is safe to share across requests. + _BLANK_ARTIFACTS = { + language: analyzer.nlp_engine.process_text("", language) + for language in SUPPORTED_LANGUAGES + } + yield + + +app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None, lifespan=lifespan) @app.exception_handler(RequestValidationError) diff --git a/apps/sim/app/.well-known/api-catalog/route.ts b/apps/sim/app/.well-known/api-catalog/route.ts new file mode 100644 index 00000000000..c11f0ce640d --- /dev/null +++ b/apps/sim/app/.well-known/api-catalog/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from 'next/server' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { DOCS_URL } from '@/lib/help-links' + +/** RFC 9727 Β§4.2: the catalog is a Linkset carrying the RFC's profile. */ +const API_CATALOG_CONTENT_TYPE = + 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"' + +/** + * RFC 9727 API catalog: one machine-readable entry point to the v2 REST API, linking its OpenAPI + * document (`service-desc`), reference docs (`service-doc`), and health endpoint (`status`) with + * the RFC 8631 relations. The anchor is this deployment's own origin, so a self-hosted instance + * describes itself. The OpenAPI document is the one published contract; its `servers` names Sim + * Cloud, so a client of another deployment uses the anchor as its base URL, as the API docs say. + * `Link: rel="api-catalog"` makes a HEAD request discoverable too (RFC 9727 Β§2). + */ +export const GET = withRouteHandler(() => { + const baseUrl = getBaseUrl() + const catalogUrl = `${baseUrl}/.well-known/api-catalog` + + return NextResponse.json( + { + linkset: [ + { + anchor: `${baseUrl}/api/v2`, + 'service-desc': [ + { href: `${DOCS_URL}/openapi.json`, type: 'application/vnd.oai.openapi+json' }, + ], + 'service-doc': [{ href: `${DOCS_URL}/api-reference/getting-started`, type: 'text/html' }], + status: [{ href: `${baseUrl}/api/health`, type: 'application/json' }], + }, + ], + }, + { + headers: { + 'Content-Type': API_CATALOG_CONTENT_TYPE, + 'Cache-Control': 'public, max-age=86400', + Link: `<${catalogUrl}>; rel="api-catalog"`, + }, + } + ) +}) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 7775d3032a8..3698bc1eb87 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -7,11 +7,11 @@ import { type DeploymentAuthResource, setDeploymentAuthCookie, } from '@/lib/core/security/deployment' -import { - type DeploymentAuthBody, - type DeploymentAuthResult, - validateDeploymentAuth, -} from '@/lib/core/security/deployment-auth' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import type { + DeploymentAuthBody, + DeploymentAuthResult, +} from '@/lib/core/security/deployment-credentials' export async function setChatAuthCookie( response: NextResponse, diff --git a/apps/sim/app/api/desktop/tool/file/route.ts b/apps/sim/app/api/desktop/tool/file/route.ts index 9829a64e681..9a2e923dc03 100644 --- a/apps/sim/app/api/desktop/tool/file/route.ts +++ b/apps/sim/app/api/desktop/tool/file/route.ts @@ -21,8 +21,8 @@ import { import { PayloadSizeLimitError, readStreamToBufferWithLimit } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { workspaceFileVfsPath } from '@/lib/uploads/contexts/workspace' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { internalFileErrorPolicies } from '@/lib/workspace-files/api' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/files/copy/route.ts b/apps/sim/app/api/files/copy/route.ts new file mode 100644 index 00000000000..d4755885e0b --- /dev/null +++ b/apps/sim/app/api/files/copy/route.ts @@ -0,0 +1,21 @@ +import { copyFileItemsContract } from '@/lib/api/contracts/file-copy' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentCopiedFileItems } from '@/lib/workspace-files/api/copy-presenter' +import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' + +export const POST = defineInternalJsonRoute({ + contract: copyFileItemsContract, + auth: internalSessionAuth, + operation: fileCopyOperation, + rateLimit: internalRateLimits.user({ bucketName: 'files.copy' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: copyFileItems, + present: presentCopiedFileItems, +}) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index eb27d10003f..9837d594961 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -13,6 +13,7 @@ import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { getServeStoragePrefix } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs' import { createMarkdownExport, @@ -27,7 +28,6 @@ import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref' import { formatFileSize } from '@/lib/uploads/utils/file' import { verifyFileAccess } from '@/app/api/files/authorization' -import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') diff --git a/apps/sim/app/api/files/public/[token]/content/route.test.ts b/apps/sim/app/api/files/public/[token]/content/route.test.ts deleted file mode 100644 index 7ec6892222c..00000000000 --- a/apps/sim/app/api/files/public/[token]/content/route.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' - -const { mockEnforceRateLimit, mockValidateDeploymentAuth, mockResolveServableDoc } = vi.hoisted( - () => ({ - mockEnforceRateLimit: vi.fn(), - mockValidateDeploymentAuth: vi.fn(), - mockResolveServableDoc: vi.fn(), - }) -) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) - -vi.mock('@/lib/public-shares/rate-limit', () => ({ - enforcePublicFileRateLimit: mockEnforceRateLimit, -})) - -vi.mock('@/lib/core/security/deployment-auth', () => ({ - validateDeploymentAuth: mockValidateDeploymentAuth, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/uploads/documents/compile', () => ({ - resolveServableDoc: mockResolveServableDoc, -})) - -import { GET } from '@/app/api/files/public/[token]/content/route' - -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockDownloadFile = storageServiceMockFns.mockDownloadFile - -const params = (token = 'tok_1') => createRouteContext({ token }) -const request = (token = 'tok_1') => - createMockRequest({ url: `http://localhost/api/files/public/${token}/content` }) - -const passwordShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, - file: { - id: 'wf_1', - key: 'workspace/ws/secret-key.pdf', - workspaceId: 'ws-1', - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 4, - }, - workspaceName: 'Acme', - ownerName: 'Jane', -} - -describe('GET /api/files/public/[token]/content', () => { - beforeEach(() => { - mockEnforceRateLimit.mockResolvedValue(null) - mockResolveActiveShareByToken.mockResolvedValue(passwordShare) - mockDownloadFile.mockResolvedValue(Buffer.from('data')) - mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' }) - }) - - it('returns 401 and never reads storage when a password share is unauthorized', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'auth_required_password', - }) - const res = await GET(request(), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('auth_required_password') - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it('answers 413 rather than 500 when the shared file is too large to serve resident', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) - mockDownloadFile.mockRejectedValueOnce( - new PayloadSizeLimitError({ - label: 'storage download', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, - observedBytes: 5 * 1024 * 1024 * 1024, - }) - ) - - const res = await GET(request(), params()) - - expect(res.status).toBe(413) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 70c289dbd86..d8fcd3a3d3d 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -1,175 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' -import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import { resolveServableDoc } from '@/lib/uploads/documents/compile' -import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' -import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' import { - createFileErrorResponse, - createFileResponse, - FileNotFoundError, - getContentType, -} from '@/app/api/files/utils' + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authorizePublicFileShare, + checkPublicFileShareContent, + readPublicFileShareContent, +} from '@/lib/public-shares/application' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' +import { createConditionalFileResponse } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileContentAPI') -/** - * GET /api/files/public/[token]/content - * Public, unauthenticated bytes for a shared file. Authorized solely by an active - * share token β€” never by workspace membership. 404 for unknown/inactive/deleted - * shares. Disposition (inline vs attachment) is resolved from the file type by - * {@link createFileResponse}; the public page's Download button uses ``. - * - * Generated office docs are stored as source; {@link resolveServableDoc} swaps in - * their prebuilt compiled binary (read-only, never compiles). Uploaded binaries - * pass through untouched, except under `preview=1`, where a format no browser - * decodes is substituted with a renderable derivative. A generated doc whose - * compiled artifact isn't built yet returns 409 rather than serving raw source - * under a binary content type. - */ +/** Binary delivery rechecks the current bearer grant and preserves the token URL's revalidation policy. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'content') if (limited) return limited - const parsed = await parseRequest(getPublicFileContentContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const preview = parsed.data.query.preview === '1' - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file } = resolved - // The same ceiling the authenticated serve route reads this object under - // (`fetchWorkspaceFileBuffer`). Without it a share link is the one way to ask - // an unauthenticated caller's request to hold a 5 GB workspace file resident. - const raw = await downloadFile({ - key: file.key, - context: 'workspace', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + const auth = await authorizePublicFileShare({ + token: parsed.data.params.token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), }) - - const servable = file.workspaceId - ? await resolveServableDoc(file.workspaceId, raw, file.originalName, { - sourceMime: file.contentType, - }) - : ({ kind: 'passthrough' } as const) - - if (servable.kind === 'unavailable') { - logger.info('Public shared doc not yet compiled', { token, key: file.key }) - return NextResponse.json( - { error: 'This document is still being prepared. Please try again shortly.' }, - { status: 409 } - ) - } - - // This response is `nosniff`, so a stored `application/octet-stream` refuses to render - // even though the bytes are fine. Resolving from the filename also keeps this route on - // the same inline allowlist as the workspace serve route. - let buffer = raw - let contentType = getContentType(file.originalName) - if (servable.kind === 'artifact') { - buffer = servable.buffer - contentType = servable.contentType - } else if ( - // Sim pages store an extensionless name β€” the record type marks them; - // legacy pages still carry .html. - (file.contentType === SIM_PAGE_CONTENT_TYPE || - file.originalName.toLowerCase().endsWith('.html')) && - isSimPageSource(raw.toString('utf8')) - ) { - // The pdf model for pages: the stored .html is source; a share serves - // the fully styled compiled document, matching the preview and serve - // routes. sim: links resolve to workspace routes (a viewer without - // workspace access simply lands on the sign-in gate). - buffer = Buffer.from( - await renderSimPageDocumentWithAssets(raw.toString('utf8'), { - workspaceId: file.workspaceId ?? undefined, - }), - 'utf8' - ) - contentType = 'text/html' - } else if (preview) { - // Only for a render request: the Download button omits `preview`, so a saved - // file is always the bytes that were shared. - const image = await resolveServableImageBytes(raw, file.key) - if (image) ({ buffer, contentType } = image) + if (!auth.authorized) return publicFileAuthDenied(auth) + if (request.method === 'HEAD') { + await checkPublicFileShareContent(auth.grant) + return new NextResponse(null, { + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.revalidate, + 'X-Content-Type-Options': 'nosniff', + }, + }) } - - // Bounding the source read does not bound the response: each branch above can - // replace it with bytes fetched or produced separately β€” a compiled artifact, a - // page with its images inlined, a transcoded derivative. This is an anonymous - // route, so the bytes it actually returns are what has to fit. - assertKnownSizeWithinLimit(buffer.length, MAX_BUFFERED_TRANSFER_BYTES, 'served file response') - - logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) - - // Anonymous access: null actor (owner-as-actor would misread as a self-download). - recordAudit({ - workspaceId: file.workspaceId ?? null, - actorId: null, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.originalName, - description: `Public share download of "${file.originalName}"`, - metadata: { - access: 'public_share', - anonymous: true, - sharedByUserId: file.userId, - fileName: file.originalName, - bytes: buffer.length, - }, + const result = await readPublicFileShareContent({ + grant: auth.grant, + preview: parsed.data.query.preview === '1', request, }) - - // Revalidate every request: a shared file can be unshared, edited, or deleted, - // so the fixed token URL must never serve stale bytes from a long-lived cache. - return createFileResponse({ - buffer, - contentType, - filename: file.originalName, - cacheControl: 'private, no-cache, must-revalidate', - }) + return createConditionalFileResponse( + { + buffer: result.buffer, + contentType: result.contentType, + filename: result.file.originalName, + cacheControl: FILE_CACHE_CONTROL.revalidate, + }, + request.headers.get('if-none-match') + ) } catch (error) { logger.error('Error serving public shared file:', error) - if (error instanceof FileNotFoundError) { - return createFileErrorResponse(error) - } - return createFileErrorResponse( - error instanceof Error ? error : new Error('Failed to serve file') - ) + return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } ) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts deleted file mode 100644 index bbdc860e5c8..00000000000 --- a/apps/sim/app/api/files/public/[token]/inline/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' - -const { mockRateLimit, mockValidateAuth, mockResolveImage } = vi.hoisted(() => ({ - mockRateLimit: vi.fn(), - mockValidateAuth: vi.fn(), - mockResolveImage: vi.fn(), -})) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) -vi.mock('@/lib/public-shares/rate-limit', () => ({ enforcePublicFileRateLimit: mockRateLimit })) -vi.mock('@/lib/core/security/deployment-auth', () => ({ validateDeploymentAuth: mockValidateAuth })) -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) -vi.mock('@/lib/uploads/server/inline-image', () => ({ - resolveWorkspaceInlineImage: mockResolveImage, -})) - -import { GET } from '@/app/api/files/public/[token]/inline/route' - -const { mockResolveActiveShareByToken: mockResolveShare } = publicSharesMockFns - -const mockDownloadFile = storageServiceMockFns.mockDownloadFile - -const TOKEN = 'tok_share_123456' -const DOC_KEY = 'workspace/ws-1/doc.md' -const IMG_KEY = 'workspace/ws-1/photo.png' -const FILE_ID = 'wf_YwDXi8eWOkTxn0sbgChlB' -const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) - -const params = createRouteContext({ token: TOKEN }) -const req = (q: string) => - createMockRequest({ url: `http://localhost/api/files/public/${TOKEN}/inline?${q}` }) - -const share = { - share: { id: 'sh_1', token: TOKEN, authType: 'public' }, - file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' }, - workspaceName: 'Acme', - ownerName: 'Jane', -} - -/** doc bytes embed the image via the view form; image bytes are a real PNG */ -function downloadByKey(docContent = `![a](/api/files/view/${FILE_ID})`) { - return ({ key }: { key: string }) => - Promise.resolve(key === DOC_KEY ? Buffer.from(docContent, 'utf-8') : PNG) -} - -describe('GET /api/files/public/[token]/inline', () => { - beforeEach(() => { - mockRateLimit.mockResolvedValue(null) - mockResolveShare.mockResolvedValue(share) - mockValidateAuth.mockResolvedValue({ authorized: true }) - mockResolveImage.mockResolvedValue({ - key: IMG_KEY, - contentType: 'image/png', - filename: 'photo.png', - }) - mockDownloadFile.mockImplementation(downloadByKey()) - }) - - it('rejects exhausted image budgets before share lookup, authentication, or storage reads', async () => { - const limited = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers: { 'Retry-After': '60' } } - ) - mockRateLimit.mockResolvedValue(limited) - - const response = await GET(req(`fileId=${FILE_ID}`), params) - - expect(response.status).toBe(429) - expect(response.headers.get('Retry-After')).toBe('60') - expect(mockResolveShare).not.toHaveBeenCalled() - expect(mockValidateAuth).not.toHaveBeenCalled() - expect(mockResolveImage).not.toHaveBeenCalled() - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it.each(['fileId', 'key'] as const)( - 'serves a referenced %s beyond the export bundle limit', - async (kind) => { - const earlierImages = Array.from( - { length: 50 }, - (_, index) => `![earlier](/api/files/view/wf_earlier_${index})` - ) - const src = - kind === 'fileId' - ? `/api/files/view/${FILE_ID}` - : `/api/files/serve/${encodeURIComponent(IMG_KEY)}` - mockDownloadFile.mockImplementation( - downloadByKey([...earlierImages, `![last](${src})`].join('\n\n')) - ) - - const response = await GET( - req(`${kind}=${encodeURIComponent(kind === 'fileId' ? FILE_ID : IMG_KEY)}`), - params - ) - - expect(response.status).toBe(200) - expect(mockResolveImage).toHaveBeenCalledExactlyOnceWith('ws-1', { - [kind]: kind === 'fileId' ? FILE_ID : IMG_KEY, - }) - } - ) - - it('404s when the reference is not embedded in the shared document', async () => { - mockDownloadFile.mockImplementation(downloadByKey('no images here')) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() - }) - - it.each([ - `[link](/api/files/view/${FILE_ID})`, - `\`![image](/api/files/view/${FILE_ID})\``, - ``, - ``, - `
`, - `inline text`, - `![external](https://example.com/api/files/view/${FILE_ID})`, - ])('does not extend a share to an image mentioned as %s', async (source) => { - mockDownloadFile.mockImplementation(downloadByKey(source)) - - const response = await GET(req(`fileId=${FILE_ID}`), params) - - expect(response.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - }) - - it('404s when the referenced file is not in the document workspace', async () => { - mockResolveImage.mockResolvedValue(null) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(404) - }) - - it('401s and never reads storage when the share is unauthorized', async () => { - mockValidateAuth.mockResolvedValue({ authorized: false, error: 'auth_required_password' }) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(401) - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it('bounds both reads: the doc scan tightly, the served image at the transfer ceiling', async () => { - await GET(req(`fileId=${FILE_ID}`), params) - - const [docRead, imageRead] = mockDownloadFile.mock.calls.map(([args]) => args) - // The doc is scanned and discarded (and decoded to UTF-16 on top of the buffer), - // so it must not inherit the ceiling of a file this route actually serves. - expect(docRead.key).toBe(DOC_KEY) - expect(docRead.maxBytes).toBeGreaterThan(0) - expect(docRead.maxBytes).toBeLessThan(MAX_BUFFERED_TRANSFER_BYTES) - expect(imageRead.key).toBe(IMG_KEY) - expect(imageRead.maxBytes).toBe(MAX_BUFFERED_TRANSFER_BYTES) - }) - - it('fails the referenced-by-doc gate closed when the document is too large to scan', async () => { - mockDownloadFile.mockImplementation(({ key }: { key: string }) => - key === DOC_KEY - ? Promise.reject( - new PayloadSizeLimitError({ - label: 'storage download', - maxBytes: 10 * 1024 * 1024, - observedBytes: 5 * 1024 * 1024 * 1024, - }) - ) - : Promise.resolve(PNG) - ) - - const res = await GET(req(`fileId=${FILE_ID}`), params) - - expect(res.status).toBe(404) - // The gate could not be verified, so the image must never be read at all. - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index f72547accc1..638eec5dda0 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -1,143 +1,55 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authorizePublicFileShare, + readPublicFileShareInline, +} from '@/lib/public-shares/application' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' -import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' -import { serveInlineImage } from '@/app/api/files/serve-inline-image' -import { createFileErrorResponse, FileNotFoundError } from '@/app/api/files/utils' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' +import { createConditionalFileResponse } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicInlineFileAPI') -/** - * Ceiling on the shared document read for the referenced-by-doc gate below. - * - * Far tighter than the ceiling on a file this route SERVES, because these bytes are - * never served β€” they are scanned for image references and discarded, and scanning - * decodes them to UTF-16 on top of the buffer, so the resident cost is roughly double - * the read. A share can point at any workspace file, admitted at 5 GB, and this route - * is anonymous; nothing a person writes as a document approaches even this bound. - */ -const MAX_INLINE_REF_SCAN_BYTES = 10 * 1024 * 1024 - -/** - * GET /api/files/public/[token]/inline?key=|fileId= - * - * Cascades a markdown document's public share to the images it embeds, so a logged-out viewer sees them - * instead of broken icons. The share grants the document bytes; this route extends that grant to the - * document's referenced images only, behind three gates that together hold the security boundary: - * - * 1. Referenced-by-doc β€” the requested key/id must be embedded as an image by the shared document's - * current bytes. The token is a capability for the document and its embeds, never an arbitrary - * workspace file, and never one the document merely links to or mentions in prose. - * 2. Same-workspace β€” the referenced file must be a `workspace` file in the document's own workspace - * ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author - * can write but must never resolve) from loading. - * 3. Content-truth β€” the served content type is sniffed from the bytes, not the client-declared type, - * and only genuine raster images are served. A file spoofing `image/png` while holding HTML/SVG is - * refused rather than rendered inline. - */ +/** The bearer operation extends a document's grant only to its current same-owner raster embeds. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'inline') if (limited) return limited - const parsed = await parseRequest(getPublicInlineFileContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const ref = parsed.data.query - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file: doc } = resolved - if (!doc.workspaceId) { - throw new FileNotFoundError('Not found') - } - - // Referenced-by-doc gate: the share grants exactly the images the document embeds. - // A document too large to scan fails the gate like any other unverifiable - // reference β€” the grant cannot be extended to an embed we were unable to confirm. - let docText: string - try { - const docBuffer = await downloadFile({ - key: doc.key, - context: 'workspace', - maxBytes: MAX_INLINE_REF_SCAN_BYTES, - }) - docText = docBuffer.toString('utf-8') - } catch (error) { - if (!isPayloadSizeLimitError(error)) throw error - logger.info('Shared document too large to scan for embedded references', { token }) - throw new FileNotFoundError('Not found') - } - const target = ref.fileId ? { fileId: ref.fileId } : ref.key ? { key: ref.key } : null - if (!target || !hasEmbeddedFileRef(docText, target)) { - throw new FileNotFoundError('Not found') - } - - // Same-workspace gate: resolve scoped to the document's own workspace. - const image = await resolveWorkspaceInlineImage(doc.workspaceId, ref) - if (!image) { - throw new FileNotFoundError('Not found') - } - - // Content-truth gate (`sniff`): render only genuine raster image bytes; audit after. - const response = await serveInlineImage(image, { sniff: true }) - - // Anonymous access: null actor (owner-as-actor would misread as a self-download). - recordAudit({ - workspaceId: doc.workspaceId, - actorId: null, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceName: image.filename, - description: `Public share inline image "${image.filename}"`, - metadata: { - access: 'public_share', - anonymous: true, - inline: true, - sharedByUserId: doc.userId, - }, + const auth = await authorizePublicFileShare({ + token: parsed.data.params.token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), + }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const result = await readPublicFileShareInline({ + grant: auth.grant, + ...parsed.data.query, request, }) - - return response + return createConditionalFileResponse( + { + buffer: result.buffer, + contentType: result.contentType, + filename: result.servedFileName, + cacheControl: FILE_CACHE_CONTROL.revalidate, + }, + request.headers.get('if-none-match') + ) } catch (error) { - if (error instanceof FileNotFoundError) { - return createFileErrorResponse(error) - } logger.error('Error serving public inline image:', error) - return createFileErrorResponse( - error instanceof Error ? error : new Error('Failed to serve file') - ) + return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } ) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts deleted file mode 100644 index 77a8c162acd..00000000000 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { requestUtilsMockFns } from '@sim/testing' -import { createRouteContext } from '@sim/testing/helpers/http' -import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock' -import { emailTemplatesMock, emailTemplatesMockFns } from '@sim/testing/mocks/email-templates.mock' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsEmailAllowed, - mockSetDeploymentAuthCookie, - mockGenerateOTP, - mockStoreOTP, - mockGetOTP, - mockDeleteOTP, - mockIncrementOTPAttempts, - mockDecodeOTPValue, - mockAfterResponse, -} = vi.hoisted(() => ({ - mockIsEmailAllowed: vi.fn(), - mockSetDeploymentAuthCookie: vi.fn(), - mockGenerateOTP: vi.fn(), - mockStoreOTP: vi.fn(), - mockGetOTP: vi.fn(), - mockDeleteOTP: vi.fn(), - mockIncrementOTPAttempts: vi.fn(), - mockDecodeOTPValue: vi.fn(), - mockAfterResponse: vi.fn(), -})) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) -vi.mock('@/lib/core/security/deployment', () => ({ - isEmailAllowed: mockIsEmailAllowed, - setDeploymentAuthCookie: mockSetDeploymentAuthCookie, -})) -vi.mock('@/lib/core/security/otp', () => ({ - generateOTP: mockGenerateOTP, - storeOTP: mockStoreOTP, - getOTP: mockGetOTP, - deleteOTP: mockDeleteOTP, - incrementOTPAttempts: mockIncrementOTPAttempts, - decodeOTPValue: mockDecodeOTPValue, - MAX_OTP_ATTEMPTS: 5, - OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, - OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, - OTP_RESOURCE_RATE_LIMIT: { maxTokens: 100, refillRate: 100, refillIntervalMs: 1000 }, -})) -vi.mock('@/components/emails', () => emailTemplatesMock) -vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) -vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) -vi.mock('@/lib/core/utils/after-response', () => ({ - afterResponse: mockAfterResponse, -})) - -import { PUT, POST as routePost } from '@/app/api/files/public/[token]/otp/route' - -const { mockSendEmail } = emailMailerMockFns -const { mockRenderOTPEmail } = emailTemplatesMockFns -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockCheckRateLimitDirect = rateLimiterMockFns.mockCheckRateLimitDirect - -const POST: typeof routePost = async (...args) => { - const response = await routePost(...args) - const task = mockAfterResponse.mock.calls.at(-1)?.[0] as (() => Promise) | undefined - if (task) await task() - return response -} - -const params = (token = 'tok_1') => createRouteContext({ token }) -const post = (email: string, token = 'tok_1') => - createMockRequest({ - method: 'POST', - url: `http://localhost/api/files/public/${token}/otp`, - body: { email }, - }) -const put = (email: string, otp: string, token = 'tok_1') => - createMockRequest({ - method: 'PUT', - url: `http://localhost/api/files/public/${token}/otp`, - body: { email, otp }, - }) - -const emailShare = { - share: { id: 'sh_1', authType: 'email', password: null, allowedEmails: ['@acme.com'] }, - file: { originalName: 'report.pdf' }, -} - -describe('POST /api/files/public/[token]/otp', () => { - beforeEach(() => { - mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) - mockResolveActiveShareByToken.mockResolvedValue(emailShare) - mockIsEmailAllowed.mockReturnValue(true) - mockGenerateOTP.mockReturnValue('123456') - mockRenderOTPEmail.mockResolvedValue('') - mockSendEmail.mockResolvedValue({ success: true }) - }) - - it('returns the generic acceptance response for an email not on the allow-list', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - const res = await POST(post('user@evil.com'), params()) - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('does not consume a send bucket for a rejected email without a client IP', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - mockIsEmailAllowed.mockReturnValueOnce(false) - - const res = await POST(post('user@evil.com'), params()) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockAfterResponse).toHaveBeenCalledTimes(1) - expect(mockCheckRateLimitDirect).not.toHaveBeenCalled() - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('returns 429 when the IP rate limit is exceeded', async () => { - mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) - const res = await POST(post('user@acme.com'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('1') - }) - - it('returns the generic acceptance response when the email rate limit is exceeded', async () => { - mockCheckRateLimitDirect - .mockResolvedValueOnce({ allowed: true }) - .mockResolvedValueOnce({ allowed: true }) - .mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('retains resource and email backstops when the client IP cannot be resolved', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2) - expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( - 1, - 'file-otp:resource:sh_1', - expect.any(Object), - { failClosed: true } - ) - expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( - 2, - 'file-otp:email:sh_1:user@acme.com', - expect.any(Object), - { failClosed: true } - ) - }) -}) - -describe('PUT /api/files/public/[token]/otp', () => { - beforeEach(() => { - mockResolveActiveShareByToken.mockResolvedValue(emailShare) - mockIsEmailAllowed.mockReturnValue(true) - mockGetOTP.mockResolvedValue('123456:0') - mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 }) - }) - - it('rejects a valid code when the email is no longer allowed', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - - const res = await PUT(put('user@acme.com', '123456'), params()) - - expect(res.status).toBe(403) - expect(mockGetOTP).not.toHaveBeenCalled() - expect(mockDeleteOTP).not.toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('rejects a wrong code with 400 and increments attempts', async () => { - mockIncrementOTPAttempts.mockResolvedValueOnce('incremented') - const res = await PUT(put('user@acme.com', '000000'), params()) - expect(res.status).toBe(400) - expect(mockIncrementOTPAttempts).toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 429 when attempts are exhausted on a wrong code', async () => { - mockIncrementOTPAttempts.mockResolvedValueOnce('locked') - const res = await PUT(put('user@acme.com', '000000'), params()) - expect(res.status).toBe(429) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 87dec5ec3e0..11b9fcadbcb 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -1,216 +1,76 @@ import { createLogger } from '@sim/logger' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestPublicFileOtpContract, verifyPublicFileOtpContract, } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' -import { isEmailAllowed, setDeploymentAuthCookie } from '@/lib/core/security/deployment' -import { - decodeOTPValue, - deleteOTP, - generateOTP, - getOTP, - incrementOTPAttempts, - MAX_OTP_ATTEMPTS, - OTP_EMAIL_RATE_LIMIT, - OTP_IP_RATE_LIMIT, - OTP_RESOURCE_RATE_LIMIT, - storeOTP, -} from '@/lib/core/security/otp' +import { OTP_IP_RATE_LIMIT } from '@/lib/core/security/otp' import { afterResponse } from '@/lib/core/utils/after-response' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { + requestPublicFileShareOtp, + verifyPublicFileShareOtp, +} from '@/lib/public-shares/application' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileOtpAPI') - const rateLimiter = new RateLimiter() -const SHARE_EMAIL_LABEL = 'a shared file' - -function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { - const response = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) - response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) - return response -} - -function otpRequestAccepted(): NextResponse { - return NextResponse.json({ message: 'Verification code sent' }) -} - -async function deliverOtp(requestId: string, shareId: string, email: string): Promise { - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:resource:${shareId}`, - OTP_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] OTP resource rate limit exceeded for share ${shareId}`) - return - } - - const emailRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:email:${shareId}:${email}`, - OTP_EMAIL_RATE_LIMIT, - { failClosed: true } - ) - if (!emailRateLimit.allowed) { - logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`) - return - } - - const otp = generateOTP() - await storeOTP('file', shareId, email, otp) - - const emailHtml = await renderOTPEmail(otp, 'email-verification', SHARE_EMAIL_LABEL) - const emailResult = await sendEmail({ - to: email, - subject: getOtpSubject(SHARE_EMAIL_LABEL), - html: emailHtml, - }) - if (!emailResult.success) { - logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return - } - - logger.info(`[${requestId}] OTP sent for share ${shareId}`) -} - -/** - * POST /api/files/public/[token]/otp - * Sends a 6-digit verification code to an allow-listed email for an email-gated share. - */ +/** Accepted email requests retain one response; the application delivers only to authorized recipients. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const ip = getClientIp(request) if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( + const limited = await rateLimiter.checkRateLimitDirect( `file-otp:ip:${ip}`, OTP_IP_RATE_LIMIT, { failClosed: true } ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`) - return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs) - } + if (!limited.allowed) + return publicFileAuthDenied({ + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limited.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs, + }) } - const parsed = await parseRequest(requestPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - // Normalize once so allow-list matching, OTP storage, and the verify lookup - // all key off the same value (allow-list entries are stored lowercase). - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'email') { - return NextResponse.json( - { error: 'This file does not use email authentication' }, - { status: 400 } - ) - } - const emailAllowed = isEmailAllowed(email, resolved.share.allowedEmails) - - afterResponse(async () => { - if (!emailAllowed) return - await deliverOtp(requestId, resolved.share.id, email) + const accepted = await requestPublicFileShareOtp({ + token: parsed.data.params.token, + email: parsed.data.body.email, }) - return otpRequestAccepted() + afterResponse(accepted.deliver) + return NextResponse.json({ message: 'Verification code sent' }) } catch (error) { - logger.error(`[${requestId}] Error processing OTP request:`, error) - return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + logger.error('Error processing OTP request:', error) + return publicFileErrorResponse(error, 'Failed to process request') } } ) -/** - * PUT /api/files/public/[token]/otp - * Verifies the code and, on success, sets the `file_auth_{shareId}` cookie. - */ +/** The application consumes a correct OTP and returns the current resource-policy cookie. */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(verifyPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const { otp } = parsed.data.body - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'email') { - return NextResponse.json( - { error: 'This file does not use email authentication' }, - { status: 400 } - ) - } - if (!isEmailAllowed(email, resolved.share.allowedEmails)) { - return NextResponse.json({ error: 'Email not authorized' }, { status: 403 }) - } - - const storedValue = await getOTP('file', resolved.share.id, email) - if (!storedValue) { - return NextResponse.json( - { error: 'No verification code found, request a new one' }, - { status: 400 } - ) - } - - const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) - if (attempts >= MAX_OTP_ATTEMPTS) { - await deleteOTP('file', resolved.share.id, email) - return NextResponse.json( - { error: 'Too many failed attempts. Please request a new code.' }, - { status: 429 } - ) - } - - if (storedOTP !== otp) { - const result = await incrementOTPAttempts('file', resolved.share.id, email, storedValue) - if (result === 'locked') { - return NextResponse.json( - { error: 'Too many failed attempts. Please request a new code.' }, - { status: 429 } - ) - } - return NextResponse.json({ error: 'Invalid verification code' }, { status: 400 }) - } - - await deleteOTP('file', resolved.share.id, email) - - const response = NextResponse.json({ authType: resolved.share.authType }) - await setDeploymentAuthCookie({ - response, - cookiePrefix: 'file', - resource: resolved.share, - verifiedEmail: email, + const auth = await verifyPublicFileShareOtp({ + token: parsed.data.params.token, + ...parsed.data.body, }) - logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`) + if (!auth.authorized) return publicFileAuthDenied(auth) + const response = NextResponse.json({ authType: auth.authType }) + response.cookies.set(auth.cookie) return response } catch (error) { - logger.error(`[${requestId}] Error verifying OTP:`, error) - return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + logger.error('Error verifying OTP:', error) + return publicFileErrorResponse(error, 'Failed to process request') } } ) diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts deleted file mode 100644 index bace6e2f8fb..00000000000 --- a/apps/sim/app/api/files/public/[token]/route.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnforceRateLimit, mockValidateDeploymentAuth, mockSetDeploymentAuthCookie } = - vi.hoisted(() => ({ - mockEnforceRateLimit: vi.fn(), - mockValidateDeploymentAuth: vi.fn(), - mockSetDeploymentAuthCookie: vi.fn(), - })) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) - -vi.mock('@/lib/public-shares/rate-limit', () => ({ - enforcePublicFileRateLimit: mockEnforceRateLimit, -})) - -vi.mock('@/lib/core/security/deployment-auth', () => ({ - validateDeploymentAuth: mockValidateDeploymentAuth, -})) - -vi.mock('@/lib/core/security/deployment', () => ({ - setDeploymentAuthCookie: mockSetDeploymentAuthCookie, -})) - -import { NextResponse } from 'next/server' -import { GET, POST } from '@/app/api/files/public/[token]/route' - -const mockResolveActiveShareByToken = publicSharesMockFns.mockResolveActiveShareByToken - -const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) -const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`) -const postRequest = (password: string, token = 'tok_1') => - new NextRequest(`http://localhost/api/files/public/${token}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ password }), - }) - -const publicShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, - file: { - id: 'wf_1', - key: 'workspace/ws/secret-key.pdf', - workspaceId: 'ws-secret', - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 2048, - }, - workspaceName: 'Acme Workspace', - ownerName: 'Jane Doe', -} - -const passwordShare = { - ...publicShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, -} - -describe('GET /api/files/public/[token]', () => { - beforeEach(() => { - mockEnforceRateLimit.mockResolvedValue(null) // allow by default - mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default - }) - - it('returns 429 when the per-IP rate limit is exceeded', async () => { - mockEnforceRateLimit.mockResolvedValueOnce( - NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }) - ) - const res = await GET(request(), params()) - expect(res.status).toBe(429) - expect(mockResolveActiveShareByToken).not.toHaveBeenCalled() - }) - - it('returns public-safe metadata without leaking the key or workspace id', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce(publicShare) - const res = await GET(request(), params()) - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ - token: 'tok_1', - name: 'report.pdf', - type: 'application/pdf', - size: 2048, - workspaceName: 'Acme Workspace', - ownerName: 'Jane Doe', - }) - expect(JSON.stringify(body)).not.toContain('secret-key') - expect(JSON.stringify(body)).not.toContain('ws-secret') - }) - - it('returns 401 auth_required_password for a password share without a valid cookie', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare) - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'auth_required_password', - }) - const res = await GET(request(), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('auth_required_password') - expect(mockValidateDeploymentAuth).toHaveBeenCalledWith( - expect.any(String), - passwordShare.share, - expect.anything(), - undefined, - 'file' - ) - }) -}) - -describe('POST /api/files/public/[token]', () => { - beforeEach(() => { - mockResolveActiveShareByToken.mockResolvedValue(passwordShare) - }) - - it('sets the file_auth cookie and returns the authType on a correct password', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) - const res = await POST(postRequest('hunter2'), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ authType: 'password' }) - expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ - response: expect.anything(), - cookiePrefix: 'file', - resource: passwordShare.share, - }) - }) - - it('refuses to mint a cookie for a non-password (e.g. public) share', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce({ - ...passwordShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, - }) - const res = await POST(postRequest('whatever'), params()) - expect(res.status).toBe(400) - expect(mockValidateDeploymentAuth).not.toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 401 Invalid password on mismatch without setting a cookie', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'Invalid password', - }) - const res = await POST(postRequest('wrong'), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('Invalid password') - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 429 with Retry-After when password attempts are rate-limited', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'Too many attempts. Please try again later.', - status: 429, - retryAfterMs: 60_000, - }) - const res = await POST(postRequest('wrong'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('60') - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index 68493446c05..2c2aed33059 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { @@ -7,54 +6,39 @@ import { getPublicFileContract, } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { + publicFileAuthDenied, + publicFileErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authenticatePublicFileSharePassword, + authorizePublicFileShare, + readPublicFileShare, +} from '@/lib/public-shares/application' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileMetadataAPI') -/** - * GET /api/files/public/[token] - * Public, unauthenticated metadata for a shared file. Returns 404 for unknown, - * inactive, or deleted shares β€” the existence of a file is never leaked. A - * password-protected share returns 401 `auth_required_password` until a valid - * `file_auth_{shareId}` cookie is present. - */ +/** Public bearer authentication and cookie exchange retain their protocol-specific response shape. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'metadata') if (limited) return limited - const parsed = await parseRequest(getPublicFileContract, request, context) if (!parsed.success) return parsed.response const { token } = parsed.data.params - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file, workspaceName, ownerName } = resolved + const auth = await authorizePublicFileShare({ + token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), + }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const { file, workspaceName, ownerName } = await readPublicFileShare({ grant: auth.grant }) return NextResponse.json({ token, name: file.originalName, @@ -65,78 +49,29 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Error fetching public file metadata:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to fetch file') }, - { status: 500 } - ) + return publicFileErrorResponse(error, 'Failed to fetch file') } } ) -/** - * POST /api/files/public/[token] - * Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited - * via the shared deployment-auth gate; returns 401 (`Invalid password`) on - * mismatch and 429 (with `Retry-After`) when throttled. - */ +/** Exchanges a current password policy for its resource-bound HttpOnly cookie. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(authenticatePublicFileContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const { password } = parsed.data.body - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - // This endpoint authenticates password shares only. Refusing other modes - // here prevents minting a `file_auth` cookie for a `public` share (which - // `validateDeploymentAuth` would otherwise authorize), which could later - // satisfy the gate if the share is switched to `email`/`sso`. - if (resolved.share.authType !== 'password') { - return NextResponse.json( - { error: 'This file does not use password authentication' }, - { status: 400 } - ) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - { password }, - 'file' - ) - if (!auth.authorized) { - const response = NextResponse.json( - { error: auth.error ?? 'Invalid password' }, - { status: auth.status ?? 401 } - ) - if (auth.status === 429 && auth.retryAfterMs !== undefined) { - response.headers.set('Retry-After', String(Math.ceil(auth.retryAfterMs / 1000))) - } - return response - } - - const response = NextResponse.json({ authType: resolved.share.authType }) - await setDeploymentAuthCookie({ - response, - cookiePrefix: 'file', - resource: resolved.share, + const auth = await authenticatePublicFileSharePassword({ + token: parsed.data.params.token, + password: parsed.data.body.password, + clientIp: getClientIp(request), }) - logger.info('Public file share password accepted', { token, shareId: resolved.share.id }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const response = NextResponse.json({ authType: auth.authType }) + response.cookies.set(auth.cookie) return response } catch (error) { logger.error('Error authenticating public file share:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to authenticate') }, - { status: 500 } - ) + return publicFileErrorResponse(error, 'Failed to authenticate') } } ) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.test.ts b/apps/sim/app/api/files/public/[token]/sso/route.test.ts deleted file mode 100644 index 1cbbc38749c..00000000000 --- a/apps/sim/app/api/files/public/[token]/sso/route.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { requestUtilsMockFns } from '@sim/testing' -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockIsEmailAllowed } = vi.hoisted(() => ({ - mockIsEmailAllowed: vi.fn(), -})) - -vi.mock('@/lib/public-shares/manager', () => publicSharesMock) -vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed })) -vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) - -import { POST } from '@/app/api/files/public/[token]/sso/route' - -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockCheckRateLimitDirect = rateLimiterMockFns.mockCheckRateLimitDirect - -const params = (token = 'tok_1') => createRouteContext({ token }) -const post = (email: string, token = 'tok_1') => - createMockRequest({ - method: 'POST', - url: `http://localhost/api/files/public/${token}/sso`, - body: { email }, - }) - -const ssoShare = { - share: { id: 'sh_1', authType: 'sso', password: null, allowedEmails: ['@acme.com'] }, - file: { originalName: 'report.pdf' }, -} - -describe('POST /api/files/public/[token]/sso', () => { - beforeEach(() => { - mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) - mockResolveActiveShareByToken.mockResolvedValue(ssoShare) - }) - - it('returns eligible:false for a non-listed email', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - const res = await POST(post('user@evil.com'), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ eligible: false }) - }) - - it('returns 429 when rate-limited', async () => { - mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 2000 }) - const res = await POST(post('user@acme.com'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('2') - }) - - it('uses the share resource limit when the client IP cannot be resolved', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) - expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( - 'file-sso:resource:sh_1', - expect.objectContaining({ maxTokens: 100 }), - { failClosed: true } - ) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts index 2ab675b5af8..9718d8a1e86 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -1,94 +1,54 @@ import { createLogger } from '@sim/logger' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { publicFileSSOContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import type { TokenBucketConfig } from '@/lib/core/rate-limiter' -import { RateLimiter } from '@/lib/core/rate-limiter' -import { isEmailAllowed } from '@/lib/core/security/deployment' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { getPublicFileShareSsoEligibility } from '@/lib/public-shares/application' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' - const logger = createLogger('PublicFileSSOAPI') - const rateLimiter = new RateLimiter() - const SSO_IP_RATE_LIMIT: TokenBucketConfig = { maxTokens: 20, refillRate: 20, refillIntervalMs: 15 * 60_000, } -const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 15 * 60_000, -} - -function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { - const response = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) - response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) - return response -} - -/** - * POST /api/files/public/[token]/sso - * Reports whether an email is on the allow-list for an SSO-gated share. The actual - * authentication is the global Sim session (checked at the page/route gate). - */ +/** Eligibility preserves the existing SSO redirect flow and never grants file access by itself. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - - const ip = getClientIp(request) - if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `file-sso:ip:${ip}`, - SSO_IP_RATE_LIMIT, - { failClosed: true } - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) - return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs) + try { + const ip = getClientIp(request) + if (ip) { + const limited = await rateLimiter.checkRateLimitDirect( + `file-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT, + { failClosed: true } + ) + if (!limited.allowed) + return publicFileAuthDenied({ + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limited.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs, + }) } - } - - const parsed = await parseRequest(publicFileSSOContract, request, context) - if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'sso') { - return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 }) - } - - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `file-sso:resource:${resolved.share.id}`, - SSO_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, { - shareId: resolved.share.id, + const parsed = await parseRequest(publicFileSSOContract, request, context) + if (!parsed.success) return parsed.response + const result = await getPublicFileShareSsoEligibility({ + token: parsed.data.params.token, + email: parsed.data.body.email, }) - return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs) + return result.allowed + ? NextResponse.json({ eligible: result.eligible }) + : publicFileAuthDenied(result) + } catch (error) { + logger.error('Error checking public file SSO eligibility:', error) + return publicFileErrorResponse(error, 'Failed to process request') } - - const allowedEmails = Array.isArray(resolved.share.allowedEmails) - ? (resolved.share.allowedEmails as string[]) - : [] - return NextResponse.json({ eligible: isEmailAllowed(email, allowedEmails) }) } ) diff --git a/apps/sim/app/api/files/serve-inline-image.ts b/apps/sim/app/api/files/serve-inline-image.ts deleted file mode 100644 index f984413fd52..00000000000 --- a/apps/sim/app/api/files/serve-inline-image.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { NextResponse } from 'next/server' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { sniffImageContentType } from '@/lib/uploads/utils/validation' -import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils' - -const logger = createLogger('InlineImageServe') - -/** - * An embedded image is authenticated content served from a fixed inline URL, and the file behind it can - * be DELETED or its access REVOKED at any time β€” so it always revalidates, letting each request re-run the - * server-side deletion/authorization check rather than serving a stale (possibly no-longer-authorized) - * image from cache. Private so no shared cache/CDN ever stores it. - */ -const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate' - -/** - * Download and respond with an already-workspace-scoped inline image β€” the single serving tail for both - * the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the - * served content type is derived from the bytes and non-raster content is refused with 404; otherwise the - * stored content type is served, matching the in-app serve route. - */ -export async function serveInlineImage( - image: ResolvedInlineImage, - { sniff }: { sniff: boolean } -): Promise { - const buffer = await downloadFile({ - key: image.key, - context: 'workspace', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, - }) - - let contentType = image.contentType - if (sniff) { - const sniffed = sniffImageContentType(buffer) - if (!sniffed) { - logger.warn('Embedded reference is not a renderable image', { key: image.key }) - throw new FileNotFoundError('Not found') - } - contentType = sniffed - } - - return createFileResponse({ - buffer, - contentType, - filename: image.filename, - cacheControl: INLINE_CACHE_CONTROL, - }) -} diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 447460a1b8e..8b4f9f6a573 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -13,7 +13,12 @@ import { createSessionPrincipal, } from '@sim/testing/factories/principal.factory' import { createRouteContext } from '@sim/testing/helpers/http' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { fileUtilsMock, fileUtilsMockFns } from '@sim/testing/mocks/file-utils.mock' +import { + fileUtilsServerMock, + fileUtilsServerMockFns, +} from '@sim/testing/mocks/file-utils-server.mock' import { filesAuthorizationMock, filesAuthorizationMockFns, @@ -36,6 +41,8 @@ const { mockReadFile, mockAuthenticateWorkspaceFile, mockReadWorkspaceFileContentByKey, + mockReadWorkspaceFileRecordByKey, + mockAuthorizeWorkspaceFileRecordByKey, mockResolveServableDocBytes, mockGetContentType, mockFindLocalFile, @@ -57,6 +64,8 @@ const { mockReadFile: vi.fn(), mockAuthenticateWorkspaceFile: vi.fn(), mockReadWorkspaceFileContentByKey: vi.fn(), + mockReadWorkspaceFileRecordByKey: vi.fn(), + mockAuthorizeWorkspaceFileRecordByKey: vi.fn(async () => undefined), mockResolveServableDocBytes: vi.fn(), mockGetContentType: vi.fn(), mockFindLocalFile: vi.fn(), @@ -86,6 +95,9 @@ vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/uploads/utils/file', () => fileUtilsMock) +vi.mock('@/lib/uploads/utils/file.server', () => fileUtilsServerMock) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) vi.mock('@/lib/uploads/setup.server', () => ({})) @@ -106,6 +118,10 @@ vi.mock('@/lib/workspace-files/api', () => ({ vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({ readWorkspaceFileContentByKey: { execute: mockReadWorkspaceFileContentByKey }, + readWorkspaceFileRecordByKey: { + execute: mockReadWorkspaceFileRecordByKey, + authorize: mockAuthorizeWorkspaceFileRecordByKey, + }, })) vi.mock('@/lib/uploads/documents/compile', () => ({ @@ -161,6 +177,17 @@ describe('File Serve API Route', () => { }, content: Buffer.from('generated source'), }) + mockReadWorkspaceFileRecordByKey.mockResolvedValue({ + file: { + id: 'file-1', + workspaceId: 'test-workspace-id', + name: 'report.pdf', + key: 'workspace/test-workspace-id/report.pdf', + type: 'text/x-pdflibjs', + size: 16, + contentUpdatedAt: new Date('2026-09-06'), + }, + }) mockResolveServableDocBytes.mockImplementation( async ({ rawBuffer, fileName }: { rawBuffer: Buffer; fileName: string }) => ({ buffer: rawBuffer, @@ -173,24 +200,23 @@ describe('File Serve API Route', () => { mockReadFile(filePath) ) mockCreateFileResponse.mockImplementation( - (file: { buffer: Buffer; contentType: string; filename: string }) => { + (file: { buffer: Buffer; contentType: string; filename: string; cacheControl?: string }) => { return new Response(file.buffer, { status: 200, headers: { 'Content-Type': file.contentType, + ...(file.cacheControl ? { 'Cache-Control': file.cacheControl } : {}), 'Content-Disposition': `inline; filename="${file.filename}"`, }, }) } ) - // Delegates so the existing assertions on the response payload β€” including its - // Cache-Control β€” read the same call list whichever helper the route reached for. mockCreateConditionalFileResponse.mockImplementation((file: unknown) => mockCreateFileResponse(file) ) - mockFileErrorResponse.mockImplementation((error: Error) => { + mockFileErrorResponse.mockImplementation((error: Error, status = 500) => { return new Response(JSON.stringify({ error: error.name, message: error.message }), { - status: error.name === 'FileNotFoundError' ? 404 : 500, + status: error.name === 'FileNotFoundError' ? 404 : status, headers: { 'Content-Type': 'application/json' }, }) }) @@ -284,9 +310,9 @@ describe('File Serve API Route', () => { // The real createFileErrorResponse owns the status mapping; mirror it here so the // route's own error path is what decides, not the mock's default 500. mockFileErrorResponse.mockImplementation( - (error: Error) => + (error: Error, status = 500) => new Response(JSON.stringify({ error: error.name }), { - status: error.name === 'PayloadSizeLimitError' ? 413 : 500, + status: error.name === 'PayloadSizeLimitError' ? 413 : status, }) ) @@ -312,32 +338,79 @@ describe('File Serve API Route', () => { }, }) - async function serveVersionedDoc(dependsOnReferencedFiles: boolean) { + async function serveVersionedDoc( + dependsOnReferencedFiles: boolean, + method: 'GET' | 'HEAD' = 'GET' + ) { mockResolveStoredFileContext.mockResolvedValue('workspace') mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') mockAuthenticateWorkspaceFile.mockResolvedValue(principal) - mockResolveServableDocBytes.mockResolvedValue({ + fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({ buffer: Buffer.from('compiled'), contentType: 'application/pdf', ...(dependsOnReferencedFiles ? { dependsOnReferencedFiles: true } : {}), }) const req = createMockRequest({ + method, url: 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf?v=1756684800000', }) - await GET(req, createRouteContext({ path: ['workspace', 'test-workspace-id', 'report.pdf'] })) - return mockCreateFileResponse.mock.calls.at(-1)?.[0] + const response = await GET( + req, + createRouteContext({ path: ['workspace', 'test-workspace-id', 'report.pdf'] }) + ) + expect(response.status).toBe(200) + return response } + it('authorizes HEAD without compiling a representation or inventing its length', async () => { + const response = await serveVersionedDoc(true, 'HEAD') + expect(await response.text()).toBe('') + expect(response.headers.get('Content-Length')).toBeNull() + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mockReadWorkspaceFileRecordByKey).toHaveBeenCalled() + expect(fileUtilsServerMockFns.mockDownloadServableFileFromStorage).not.toHaveBeenCalled() + }) + it('caches a versioned document immutably when its bytes derive from the stored source alone', async () => { - expect(await serveVersionedDoc(false)).toEqual( - expect.objectContaining({ cacheControl: 'private, max-age=31536000, immutable' }) - ) + const response = await serveVersionedDoc(false) + expect(response.headers.get('Cache-Control')).toBe('private, max-age=31536000, immutable') }) - it('attaches a validator to a revalidated response so the next check can be answered 304', async () => { - await serveVersionedDoc(true) - expect(mockCreateConditionalFileResponse).toHaveBeenCalled() + it('requires revalidation for a versioned document with referenced inputs', async () => { + const response = await serveVersionedDoc(true) + expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + }) + + it('serves stored bytes under the type their extension names, not the recorded type', async () => { + mockResolveStoredFileContext.mockResolvedValue('workspace') + mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') + mockAuthenticateWorkspaceFile.mockResolvedValue(principal) + mockReadWorkspaceFileRecordByKey.mockResolvedValue({ + file: { + id: 'file-1', + workspaceId: 'test-workspace-id', + name: 'export.csv', + key: 'workspace/test-workspace-id/export.csv', + type: 'application/vnd.ms-excel', + size: 3, + contentUpdatedAt: new Date('2026-09-06'), + }, + }) + fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({ + buffer: Buffer.from('a,b'), + contentType: 'application/vnd.ms-excel', + }) + + const response = await GET( + createMockRequest({ + url: 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/export.csv', + }), + createRouteContext({ path: ['workspace', 'test-workspace-id', 'export.csv'] }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/csv') }) }) }) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index fb06b9262a1..478bab67a90 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,4 +1,4 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -10,22 +10,29 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { readOrganizationChatAttachment } from '@/lib/uploads/contexts/organization-assistant/application' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/file-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { resolveServableDocBytes } from '@/lib/uploads/documents/compile' import { DocCompileUserError } from '@/lib/uploads/documents/compile-error' +import { FILE_CACHE_CONTROL, workspaceFileCacheControl } from '@/lib/uploads/server/delivery' import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' import { resolveStoredFileContext } from '@/lib/uploads/server/metadata' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { inferContextFromKey } from '@/lib/uploads/utils/file' +import { inferContextFromKey, resolveEffectiveMimeType } from '@/lib/uploads/utils/file' import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api' -import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key' +import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' +import { + readWorkspaceFileContentByKey, + readWorkspaceFileRecordByKey, +} from '@/lib/workspace-files/application/read-workspace-file-content-by-key' import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' import { type KnowledgeFileAccess, verifyFileAccess } from '@/app/api/files/authorization' @@ -110,9 +117,6 @@ async function resolveServableBytes(params: { workspaceId: string | undefined options: ServeOptions ownerKey: string | undefined - filePrincipal?: Principal - /** The stored record's content type, where the caller has the record. */ - fileType?: string signal: AbortSignal | undefined }): Promise { // `raw` is the stored source, already bounded by the read that produced it, but it @@ -140,29 +144,16 @@ async function resolveTransformedBytes(params: { workspaceId: string | undefined options: ServeOptions ownerKey: string | undefined - filePrincipal?: Principal - fileType?: string signal: AbortSignal | undefined }): Promise { - const { - buffer, - filename, - storageKey, - workspaceId, - options, - ownerKey, - filePrincipal, - fileType, - signal, - } = params + const { buffer, filename, storageKey, workspaceId, options, ownerKey, signal } = params // The pdf model for pages: a page file stores its SOURCE (frontmatter + // markdown + sim: fences) and serving compiles it to the rendered document, // the same way a .pdf key stores its script and serves the binary. Raw // requests above still return the source; bespoke/legacy HTML falls through - // untouched. Sim pages store an EXTENSIONLESS name β€” the record type marks - // them; legacy pages still carry .html. - if (fileType === SIM_PAGE_CONTENT_TYPE || filename.toLowerCase().endsWith('.html')) { + // untouched. + if (filename.toLowerCase().endsWith('.html')) { const text = buffer.toString('utf8') if (isSimPageSource(text)) { const rendered = Buffer.from( @@ -189,9 +180,7 @@ async function resolveTransformedBytes(params: { const doc = await resolveServableDocBytes({ rawBuffer: buffer, fileName: filename, - sourceMime: fileType, workspaceId, - filePrincipal, ownerKey, signal, }) @@ -212,11 +201,11 @@ function getWorkspaceIdForCompile(key: string): string | undefined { return parseWorkspaceFileKey(key) ?? undefined } -const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable' -const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' +const IMMUTABLE_CACHE_CONTROL = FILE_CACHE_CONTROL.immutable +const WORKSPACE_REVALIDATE_CACHE_CONTROL = FILE_CACHE_CONTROL.revalidate /** For the genuinely-public, pre-auth asset routes (avatars, OG images, workspace logos) β€” these are * intentionally shared-cacheable. Passed EXPLICITLY so the default response cache stays `private`. */ -const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' +const PUBLIC_ASSET_CACHE_CONTROL = FILE_CACHE_CONTROL.publicAsset /** * Cache-Control for a served file. @@ -238,10 +227,21 @@ function resolveServeCacheControl( context: string | undefined, dependsOnReferencedFiles: boolean ): string | undefined { + if (context === 'workspace') return workspaceFileCacheControl(versioned, dependsOnReferencedFiles) if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL - return context === 'workspace' || dependsOnReferencedFiles - ? WORKSPACE_REVALIDATE_CACHE_CONTROL - : undefined + return dependsOnReferencedFiles ? WORKSPACE_REVALIDATE_CACHE_CONTROL : undefined +} + +/** + * The type a rendered workspace file is served under. A rendered Sim page is HTML; every other + * representation is named by the file's extension, as {@link resolveServableDocBytes} names it for + * local and cloud files, rather than by the stored record's type, so a `.csv` recorded as + * `application/vnd.ms-excel` still opens inline. + */ +function renderedWorkspaceContentType(file: WorkspaceFileRecord, renderedType: string): string { + return file.type === SIM_PAGE_CONTENT_TYPE && renderedType === 'text/html' + ? renderedType + : resolveEffectiveMimeType(undefined, file.name) } /** @@ -269,6 +269,8 @@ export const GET = withRouteHandler( throw new FileNotFoundError('No file path provided') } + if (path[0] === 'project') throw new FileNotFoundError('File not found') + logger.info('File serve request:', { path }) const fullPath = path.join('/') @@ -331,7 +333,16 @@ export const GET = withRouteHandler( path, error: legacyAuthResult.error || 'Missing userId', }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: 'Unauthorized' }, + { + status: 401, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) } const query = fileServeQuerySchema.parse({ @@ -363,7 +374,8 @@ export const GET = withRouteHandler( options, request.signal, storageContext, - knowledgeAccess + knowledgeAccess, + request.method === 'HEAD' ) } @@ -373,12 +385,22 @@ export const GET = withRouteHandler( options, request.signal, storageContext, - knowledgeAccess + knowledgeAccess, + request.method === 'HEAD' ) } catch (error) { if (error instanceof InternalUnauthenticatedError) { logger.warn('Unauthorized file access attempt', { error: error.message }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: 'Unauthorized' }, + { + status: 401, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) } // An in-progress/incomplete doc source fails to compile β€” this is expected @@ -389,7 +411,16 @@ export const GET = withRouteHandler( logger.info('Serve: document still compiling, returning 409', { message: error.message, }) - return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 }) + return NextResponse.json( + { error: 'Document is still being generated' }, + { + status: 409, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) } const orchestrationError = asOrchestrationError( @@ -401,6 +432,15 @@ export const GET = withRouteHandler( return createFileErrorResponse(notFound) } + if (orchestrationError) { + if (orchestrationError.code === 'internal') + logServeFailure('Error serving file:', orchestrationError) + return createFileErrorResponse( + orchestrationError, + statusForOrchestrationError(orchestrationError.code) + ) + } + logServeFailure('Error serving file:', error) if (error instanceof FileNotFoundError) { @@ -423,23 +463,46 @@ async function handleWorkspaceFile( const workspaceId = getWorkspaceIdForCompile(key) if (!workspaceId) throw new FileNotFoundError(`File not found: ${key}`) - const { file, content } = await readWorkspaceFileContentByKey.execute({ - principal, - input: { key, assertedWorkspaceId: workspaceId }, - request, - }) - const ownerKey = `user:${requirePrincipalSubjectUserId(principal)}` - const resolved = await resolveServableBytes({ - buffer: content, - filename: file.name, - storageKey: key, - workspaceId, - options, - ownerKey, - filePrincipal: principal, - fileType: file.type, - signal: request.signal, - }) + const input = { key, assertedWorkspaceId: workspaceId } + if (request.method === 'HEAD') { + await readWorkspaceFileRecordByKey.execute({ principal, input, request }) + return new NextResponse(null, { + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + }) + } + let file: WorkspaceFileRecord + let resolved: ServableBytes + if (options.raw) { + const read = await readWorkspaceFileContentByKey.execute({ principal, input, request }) + file = read.file + resolved = { + buffer: read.content, + contentType: getContentType(file.name), + dependsOnReferencedFiles: false, + } + } else { + file = (await readWorkspaceFileRecordByKey.execute({ principal, input, request })).file + const rendered = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + signal: request.signal, + }) + const preview = options.preview ? await resolveServableImageBytes(rendered.buffer, key) : null + resolved = { + buffer: preview?.buffer ?? rendered.buffer, + contentType: preview?.contentType ?? renderedWorkspaceContentType(file, rendered.contentType), + dependsOnReferencedFiles: rendered.dependsOnReferencedFiles, + } + assertKnownSizeWithinLimit( + resolved.buffer.length, + MAX_BUFFERED_TRANSFER_BYTES, + 'served file response' + ) + await finishFileDelivery({ + principal, + authorize: () => readWorkspaceFileRecordByKey.authorize({ principal, input, request }), + receipt: rendered.receipt, + }) + } logger.info('Workspace file served', { fileId: file.id, @@ -467,7 +530,8 @@ async function handleLocalFile( options: ServeOptions, signal: AbortSignal | undefined, context: StorageContext, - knowledgeAccess: KnowledgeFileAccess | undefined + knowledgeAccess: KnowledgeFileAccess | undefined, + head: boolean ): Promise { const ownerKey = `user:${userId}` try { @@ -484,6 +548,13 @@ async function handleLocalFile( throw new FileNotFoundError(`File not found: ${filename}`) } + if (head) + return new NextResponse(null, { + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + }) const filePath = await findLocalFile(filename) if (!filePath) { @@ -539,7 +610,8 @@ async function handleCloudProxy( options: ServeOptions, signal: AbortSignal | undefined, context: StorageContext, - knowledgeAccess: KnowledgeFileAccess | undefined + knowledgeAccess: KnowledgeFileAccess | undefined, + head: boolean ): Promise { const ownerKey = `user:${userId}` try { @@ -558,6 +630,13 @@ async function handleCloudProxy( throw new FileNotFoundError(`File not found: ${cloudKey}`) } + if (head) + return new NextResponse(null, { + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + }) let rawBuffer: Buffer if (context === 'copilot') { diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index 02536725612..f324c33f148 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { createConditionalFileResponse, createFileResponse, - encodeFilenameForHeader, extractFilename, findLocalFile, } from '@/app/api/files/utils' diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index b668142b9e9..f8b018c6c7f 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -1,11 +1,15 @@ -import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { isPayloadSizeLimitError, readNodeStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import { ensureFileNameExtension, sanitizeFileKey } from '@/lib/uploads/utils/file' +import { + bufferedRepresentationEtag, + FILE_CACHE_CONTROL, + fileDeliveryHeaders, +} from '@/lib/uploads/server/delivery' +import { sanitizeFileKey } from '@/lib/uploads/utils/file' const logger = createLogger('FilesUtils') @@ -151,116 +155,20 @@ export async function findLocalFile(filename: string): Promise { } } -const SAFE_INLINE_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/svg+xml', - 'image/webp', - 'image/avif', - 'image/bmp', - 'image/x-icon', - 'application/pdf', - 'text/plain', - 'text/csv', - 'application/json', -]) - -const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml']) - -export function getSecureFileHeaders(filename: string, originalContentType: string) { - const extension = filename.split('.').pop()?.toLowerCase() || '' - - if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) { - return { - contentType: 'application/octet-stream', - disposition: 'attachment', - } - } - - let safeContentType = originalContentType - - if (originalContentType === 'text/html') { - safeContentType = 'text/plain' - } - - const disposition = SAFE_INLINE_TYPES.has(safeContentType) ? 'inline' : 'attachment' - - return { - contentType: safeContentType, - disposition, - } -} - -/** - * Percent-encode a filename as an RFC 8187 `ext-value`. - * - * `encodeURIComponent` alone is not enough: it leaves `'`, `(`, `)` and `*` raw, and - * none of those are `attr-char`. The apostrophe is the specific hazard β€” it is the - * delimiter in `UTF-8''name`, so a filename like `it's.pdf` would emit a third `'` - * and desync the parser. - */ -function encodeExtValue(filename: string): string { - return encodeURIComponent(filename).replace( - /['()*]/g, - (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}` - ) -} - -/** - * Build the `filename` parameters for a Content-Disposition header. - * - * The name is attacker-controlled (it is the user's `originalName`), so it can never - * be interpolated raw: a `"` closes the quoted-string early and everything after it - * is parsed as further parameters. An injected `filename*` is the payload that - * matters, because RFC 6266 tells clients to prefer `filename*` over `filename` β€” - * so the attacker's value wins and the download lands under a name the product UI - * never showed. Both parameters are therefore always emitted from sanitized input: - * the quoted form keeps only printable ASCII minus `"` and `\`, and the `filename*` - * form is fully percent-encoded. - * - * `;` is neutralized too, even though a quoted string may legally contain one: the - * quoted parameter exists as the fallback for clients that do not implement - * `filename*`, and those are the same clients liable to split parameters on a bare - * `;` without honouring the quoting. The exact name still survives in `filename*`. - */ -export function encodeFilenameForHeader(storageKey: string): string { - const filename = storageKey.split('/').pop() || storageKey - const asciiSafe = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\;]/g, '_') - // Unchanged input proves the name is printable ASCII with no `"` or `\`, so the - // quoted form alone is both safe and sufficient β€” `filename*` buys nothing here. - if (asciiSafe === filename) { - return `filename="${filename}"` - } - return `filename="${asciiSafe}"; filename*=UTF-8''${encodeExtValue(filename)}` -} - /** * Derives the served filename from the CALLER's content type (`getSecureFileHeaders` * downgrades `text/html`) before the header decision, so a derived `.html` name gets the * same forced-attachment treatment a stored `.html` file gets. */ export function createFileResponse(file: FileResponse): NextResponse { - const servedFilename = ensureFileNameExtension(file.filename, file.contentType) - - const { contentType, disposition } = getSecureFileHeaders(servedFilename, file.contentType) - - const headers: Record = { - 'Content-Type': contentType, - 'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(servedFilename)}`, - // Default to PRIVATE: this response is served only after access verification, so it must never be - // stored by a shared cache/CDN and re-served cross-user. Genuinely public assets (avatars, OG images, - // workspace logos) pass an explicit `cacheControl` (see PUBLIC_ASSET_CACHE_CONTROL in the serve route). - 'Cache-Control': file.cacheControl || 'private, no-cache', - 'X-Content-Type-Options': 'nosniff', - } - - if (contentType === 'image/svg+xml') { - headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox;" - } - - return new NextResponse(file.buffer as BodyInit, { status: 200, headers }) + return new NextResponse(file.buffer as BodyInit, { + status: 200, + headers: fileDeliveryHeaders({ + ...file, + cacheControl: file.cacheControl || FILE_CACHE_CONTROL.private, + contentLength: file.buffer.length, + }), + }) } /** @@ -294,18 +202,15 @@ export function createConditionalFileResponse( file: FileResponse, ifNoneMatch: string | null ): NextResponse { - const etag = `"${createHash('sha256').update(file.buffer).digest('base64url')}"` + const etag = bufferedRepresentationEtag(file.buffer) if (ifNoneMatchHolds(ifNoneMatch, etag)) { - // A 304 repeats the headers that govern caching, so the stored response is refreshed with the - // lifetime this request would have granted it rather than keeping the one it was stored with. - return new NextResponse(null, { - status: 304, - headers: { - ETag: etag, - 'Cache-Control': file.cacheControl || 'private, no-cache', - }, + const headers = fileDeliveryHeaders({ + ...file, + cacheControl: file.cacheControl || FILE_CACHE_CONTROL.private, }) + headers.set('ETag', etag) + return new NextResponse(null, { status: 304, headers }) } const response = createFileResponse(file) @@ -331,7 +236,10 @@ export function createFileErrorResponse(error: Error, status = 500): NextRespons error: error.name, message: error.message, }, - { status: statusCode } + { + status: statusCode, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } ) } diff --git a/apps/sim/app/api/knowledge/[id]/export/route.ts b/apps/sim/app/api/knowledge/[id]/export/route.ts index b7c9c25e560..2c4ad52080e 100644 --- a/apps/sim/app/api/knowledge/[id]/export/route.ts +++ b/apps/sim/app/api/knowledge/[id]/export/route.ts @@ -12,7 +12,7 @@ import { buildKnowledgeBundleArchive, knowledgeBundleFileName, } from '@/lib/knowledge/transfer/export-archive' -import { encodeFilenameForHeader } from '@/app/api/files/utils' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' /** GET /api/knowledge/[id]/export β€” stream a knowledge base as a bundle archive. */ export const GET = defineInternalBinaryRoute({ diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/artifact/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/artifact/route.ts new file mode 100644 index 00000000000..c18a1989e84 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/artifact/route.ts @@ -0,0 +1,30 @@ +import { readProjectFileArtifactContract } from '@/lib/api/contracts/project-files' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileContent } from '@/lib/projects/files/api' +import { readProjectFileArtifact } from '@/lib/projects/files/application' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' + +export const GET = defineInternalBinaryRoute({ + contract: readProjectFileArtifactContract, + auth: internalSessionAuth, + operation: readProjectFileArtifact.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated bounded file rendering follows the existing private preview policy', + }), + errorPolicy: internalOrchestrationErrorPolicy, + headSafe: false, + mapInput: ({ params, query }) => ({ + preview: query.preview === '1', + projectId: params.id, + fileId: params.fileId, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }), + useCase: readProjectFileArtifact, + present: ({ file, buffer, contentType }) => + presentProjectFileContent({ file: { ...file, type: contentType }, content: buffer }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/csv-preview/route.ts new file mode 100644 index 00000000000..f4e1500490f --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/csv-preview/route.ts @@ -0,0 +1,25 @@ +import { getProjectCsvPreviewContract } from '@/lib/api/contracts/project-files' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { readProjectFileCsvPreview } from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectCsvPreviewContract, + auth: internalSessionAuth, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + operation: readProjectFileCsvPreview.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.csv-preview' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }, { request }) => ({ + projectId: params.id, + fileId: params.fileId, + key: query.key, + signal: request.signal, + }), + useCase: readProjectFileCsvPreview, + present: ({ success, headers, rows, truncated }) => ({ success, headers, rows, truncated }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/export/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/export/route.ts new file mode 100644 index 00000000000..e6f575dfc15 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/export/route.ts @@ -0,0 +1,24 @@ +import { + exportProjectFileSnapshotContract, + MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES, +} from '@/lib/api/contracts/project-file-downloads' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { exportProjectFileSnapshot } from '@/lib/projects/files/application' + +export const POST = defineInternalBinaryRoute({ + contract: exportProjectFileSnapshotContract, + parseOptions: { maxBodyBytes: MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES }, + auth: internalSessionAuth, + operation: exportProjectFileSnapshot.operation, + rateLimit: internalRateLimits.none({ reason: 'Authenticated bounded Markdown snapshot export' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }), + useCase: exportProjectFileSnapshot, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/extract/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/extract/route.ts new file mode 100644 index 00000000000..41b5d123f4a --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/extract/route.ts @@ -0,0 +1,21 @@ +import { extractProjectFileContract } from '@/lib/api/contracts/project-file-extraction' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { extractProjectFile } from '@/lib/projects/files/application' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' + +export const maxDuration = 300 + +export const POST = defineInternalJsonRoute({ + contract: extractProjectFileContract, + auth: internalSessionAuth, + operation: extractProjectFile.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalFileErrorPolicies.extractArchive, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: extractProjectFile, + present: (result) => ({ success: true, ...result }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..717e120acaa --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts @@ -0,0 +1,34 @@ +import { + getProjectFileShareContract, + updateProjectFileShareContract, +} from '@/lib/api/contracts/project-file-shares' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getProjectFileShare, + projectFileOperations, + updateProjectFileShare, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectFileShareContract, + auth: internalSessionAuth, + operation: projectFileOperations.readShare, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: getProjectFileShare, +}) +export const PUT = defineInternalJsonRoute({ + contract: updateProjectFileShareContract, + auth: internalSessionAuth, + operation: projectFileOperations.updateShare, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }), + useCase: updateProjectFileShare, +}) diff --git a/apps/sim/app/api/projects/[id]/files/download/route.ts b/apps/sim/app/api/projects/[id]/files/download/route.ts new file mode 100644 index 00000000000..961374cebae --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/download/route.ts @@ -0,0 +1,21 @@ +import { downloadProjectFileItemsContract } from '@/lib/api/contracts/project-file-downloads' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { downloadProjectFileItems } from '@/lib/projects/files/application' + +export const GET = defineInternalBinaryRoute({ + contract: downloadProjectFileItemsContract, + auth: internalSessionAuth, + operation: downloadProjectFileItems.operation, + rateLimit: internalRateLimits.none({ reason: 'Authenticated bounded Project archive download' }), + errorPolicy: internalOrchestrationErrorPolicy, + headSafe: false, + mapInput: ({ params, query }) => ({ projectId: params.id, ...query }), + useCase: downloadProjectFileItems, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/projects/[id]/files/inline/route.ts b/apps/sim/app/api/projects/[id]/files/inline/route.ts new file mode 100644 index 00000000000..84efd1af8dd --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/inline/route.ts @@ -0,0 +1,32 @@ +import { getInlineProjectFileContract } from '@/lib/api/contracts/project-files' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { readProjectInlineFile } from '@/lib/projects/files/application' +import { presentFileDelivery, workspaceFileCacheControl } from '@/lib/uploads/server/delivery' + +export const GET = defineInternalBinaryRoute({ + contract: getInlineProjectFileContract, + auth: internalSessionAuth, + headSafe: false, + operation: readProjectInlineFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Authenticated embedded file delivery' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.id, + key: query.key, + referenceFileId: query.fileId, + }), + useCase: readProjectInlineFile, + present: ({ file, stream, contentLength, contentAddressed }) => + presentFileDelivery({ + body: stream, + filename: file.name, + contentType: file.type, + contentLength, + cacheControl: workspaceFileCacheControl(contentAddressed), + }), +}) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index eeab2d40d80..915bbc33cd2 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -25,10 +25,10 @@ import { adminV1ExportFolderContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceFolder } from '@/lib/folders/scope' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' -import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInternalErrorResponse, diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index 3f1617e585c..3e40349d344 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -20,10 +20,10 @@ import { NextResponse } from 'next/server' import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' -import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { adminInternalErrorResponse, diff --git a/apps/sim/app/api/v2/files/bulk-download/route.ts b/apps/sim/app/api/v2/files/bulk-download/route.ts index 5587caa1a16..00d255c5d31 100644 --- a/apps/sim/app/api/v2/files/bulk-download/route.ts +++ b/apps/sim/app/api/v2/files/bulk-download/route.ts @@ -1,33 +1,13 @@ -import { Readable } from 'node:stream' -import { createLogger } from '@sim/logger' -import { ZipArchive } from 'archiver' import { v2BulkDownloadFilesContract } from '@/lib/api/contracts/v2/files' import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { presentWorkspaceFileArchive, v2FileErrorPolicies } from '@/lib/workspace-files/api' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('V2FilesBulkDownloadAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** Opens each object only as the archiver reaches it, so peak memory stays flat. */ -function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { - return Readable.from( - (async function* () { - yield* await downloadFileStream({ - key: file.key, - context: file.storageContext ?? 'workspace', - }) - })(), - { objectMode: false } - ) -} /** * GET /api/v2/files/bulk-download β€” stream a selection of files as one zip. @@ -54,36 +34,5 @@ export const GET = defineV2BinaryRoute({ folderPaths: query.folderPaths, }), useCase: downloadWorkspaceFileItems, - present: ({ filesToZip, folderPaths, renderedDocuments, excludedFileIds }) => { - const report = excludedFileIds.length > 0 ? { partial: true, excludedFileIds } : undefined - const entryPaths = buildZipEntryPaths([ - ...filesToZip.map((file) => ({ - name: file.name, - folderPath: file.folderId ? folderPaths.get(file.folderId) : null, - contentType: file.type, - })), - ...(report ? [{ name: 'sim-download-report.json', contentType: 'application/json' }] : []), - ]) - const archive = new ZipArchive({ store: true }) - archive.on('warning', (error: Error) => { - logger.warn('Archive warning while streaming workspace files', { error }) - }) - filesToZip.forEach((file, index) => { - archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { - name: entryPaths[index], - }) - }) - if (report) { - archive.append(JSON.stringify(report, null, 2), { name: entryPaths[filesToZip.length] }) - } - archive.finalize().catch((error) => { - logger.error('Failed to finalize workspace file archive', { error }) - }) - - return { - body: nodeReadableToWebStream(archive), - contentType: 'application/zip', - contentDisposition: 'attachment; filename="workspace-files.zip"', - } - }, + present: presentWorkspaceFileArchive, }) diff --git a/apps/sim/app/api/v2/files/copy/route.ts b/apps/sim/app/api/v2/files/copy/route.ts new file mode 100644 index 00000000000..3de70475a44 --- /dev/null +++ b/apps/sim/app/api/v2/files/copy/route.ts @@ -0,0 +1,21 @@ +import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentCopiedFileItems } from '@/lib/workspace-files/api/copy-presenter' +import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items' +import { fileCopyOperations } from '@/lib/workspace-files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2CopyFileItemsContract, + auth: v2ApiKeyAuth, + operation: fileCopyOperations.copy, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: copyFileItems, + present: (result) => ({ data: presentCopiedFileItems(result) }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts index 4d8b8a2cc8f..5c1a842bcae 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts @@ -8,7 +8,7 @@ import { buildKnowledgeBundleArchive, knowledgeBundleFileName, } from '@/lib/knowledge/transfer/export-archive' -import { encodeFilenameForHeader } from '@/app/api/files/utils' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' export const dynamic = 'force-dynamic' export const revalidate = 0 diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/export/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/export/route.ts new file mode 100644 index 00000000000..282d07f3947 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/export/route.ts @@ -0,0 +1,23 @@ +import { MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES } from '@/lib/api/contracts/project-file-downloads' +import { v2ExportProjectFileSnapshotContract } from '@/lib/api/contracts/v2/project-file-downloads' +import { + defineV2BinaryRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { exportProjectFileSnapshot } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2BinaryRoute({ + contract: v2ExportProjectFileSnapshotContract, + parseOptions: { maxBodyBytes: MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES }, + auth: v2ApiKeyAuth, + operation: projectFileOperations.exportSnapshot, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, content: body.content }), + useCase: exportProjectFileSnapshot, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..8b27b2d7791 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts @@ -0,0 +1,41 @@ +import { + v2GetProjectFileShareContract, + v2UpdateProjectFileShareContract, +} from '@/lib/api/contracts/v2/project-file-shares' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + getProjectFileShare, + updateProjectFileShare, +} from '@/lib/projects/files/application/shares' + +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileShareContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), + useCase: getProjectFileShare, + present: ({ share }) => ({ data: share }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateProjectFileShareContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileId: params.fileId, + ...body, + }), + useCase: updateProjectFileShare, + present: ({ share }) => ({ data: share }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route.ts new file mode 100644 index 00000000000..899587397a0 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route.ts @@ -0,0 +1,26 @@ +import { v2UnzipProjectFileContract } from '@/lib/api/contracts/v2/project-file-extraction' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { buildFolderPath } from '@/lib/folders/paths' +import { extractProjectFile } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + +export const maxDuration = 300 + +export const POST = defineV2JsonRoute({ + contract: v2UnzipProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.extractArchive, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealExtractionAuthorization, + mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), + useCase: extractProjectFile, + present: (result) => ({ + data: { + folderPath: buildFolderPath(parseWorkspaceFileFolderDisplayPath(result.folderDisplayPath)), + extractedFileCount: result.extractedCount, + skippedFileCount: result.skippedCount, + }, + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/bulk-download/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/bulk-download/route.ts new file mode 100644 index 00000000000..f50f95dfa3c --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/bulk-download/route.ts @@ -0,0 +1,22 @@ +import { v2DownloadProjectFileItemsContract } from '@/lib/api/contracts/v2/project-file-downloads' +import { + defineV2BinaryRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { downloadProjectFileItems } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2BinaryRoute({ + contract: v2DownloadProjectFileItemsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.downloadItems, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + headSafe: false, + mapInput: ({ params, query }) => ({ projectId: params.projectId, ...query }), + useCase: downloadProjectFileItems, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/search/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/search/route.ts new file mode 100644 index 00000000000..acb8e777195 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/search/route.ts @@ -0,0 +1,28 @@ +import { parseFolderPathList } from '@/lib/api/contracts/v2/files' +import { v2SearchProjectFileContentContract } from '@/lib/api/contracts/v2/project-file-search' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { searchProjectFileContent } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2SearchProjectFileContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.searchContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + folderPaths: + query.folderPaths === undefined ? undefined : parseFolderPathList(query.folderPaths), + }), + useCase: searchProjectFileContent, + present: ({ results, count, truncated, complete, indexStatus }) => ({ + data: { results, count, truncated, complete, indexStatus }, + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts index c6d573e4d5c..1aad1b2521b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts @@ -5,9 +5,9 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { exportWorkspaceFileSnapshot } from '@/lib/workspace-files/application/export-workspace-file-snapshot' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const POST = defineInternalBinaryRoute({ contract: exportWorkspaceFileSnapshotContract, diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts index 77c794ffc6e..a7acf73517d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts @@ -15,6 +15,7 @@ import { createMockRequest } from '@sim/testing' import { createRouteContext } from '@sim/testing/helpers/http' import { auditMock } from '@sim/testing/mocks/audit.mock' import { authMockFns } from '@sim/testing/mocks/auth.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' @@ -29,6 +30,7 @@ const run = promisify(execFile) vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) vi.mock('@/lib/uploads/contexts/workspace', () => workspaceUploadsMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/posthog/server', () => posthogServerMock) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index f1ccd1ce2bd..74892fca96e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -1,36 +1,20 @@ -import { Readable } from 'node:stream' -import { createLogger } from '@sim/logger' -import { ZipArchive } from 'archiver' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalBinaryRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { + internalFileAnalytics, + internalFileErrorPolicies, + presentWorkspaceFileArchive, +} from '@/lib/workspace-files/api' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' -const logger = createLogger('WorkspaceFilesDownloadAPI') - -function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { - return Readable.from( - (async function* () { - yield* await downloadFileStream({ - key: file.key, - context: file.storageContext ?? 'workspace', - }) - })(), - { objectMode: false } - ) -} - export const GET = defineInternalBinaryRoute({ contract: downloadWorkspaceFileItemsContract, auth: internalSessionAuth, + headSafe: false, operation: downloadWorkspaceFileItems.operation, rateLimit: internalRateLimits.none({ reason: 'Internal workspace zip download' }), errorPolicy: internalFileErrorPolicies.downloadArchive, @@ -41,32 +25,5 @@ export const GET = defineInternalBinaryRoute({ }), useCase: downloadWorkspaceFileItems, onSuccess: internalFileAnalytics.bulkDownloaded, - present: ({ filesToZip, folderPaths, renderedDocuments }) => { - const entryPaths = buildZipEntryPaths( - filesToZip.map((file) => ({ - name: file.name, - folderPath: file.folderId ? folderPaths.get(file.folderId) : null, - contentType: file.type, - })) - ) - const archive = new ZipArchive({ store: true }) - archive.on('warning', (error: Error) => { - logger.warn('Archive warning while streaming workspace files', { error }) - }) - filesToZip.forEach((file, index) => { - archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { - name: entryPaths[index], - }) - }) - archive.finalize().catch((error) => { - logger.error('Failed to finalize workspace file archive', { error }) - }) - - return { - body: nodeReadableToWebStream(archive), - contentType: 'application/zip', - contentDisposition: 'attachment; filename="workspace-files.zip"', - headers: { 'Cache-Control': 'no-store' }, - } - }, + present: presentWorkspaceFileArchive, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 85f09365f27..4cfc126a79c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -9,6 +9,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-inline-file', () => ({ readWorkspaceInlineFile: { operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' }, execute: mockReadInline, + authorize: vi.fn(async () => undefined), }, })) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts index 0a3f20bf4ec..3dd6a85ce03 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -4,40 +4,25 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { presentFileDelivery, workspaceFileCacheControl } from '@/lib/uploads/server/delivery' import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' -import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' -/** - * How long the browser may reuse an embedded image, decided by whether the URL names the exact object - * that was streamed (see {@link ReadWorkspaceInlineFileResult.contentAddressed}). - * - * A content write never rewrites a storage object, so a URL that names one addresses bytes that can - * never change and the browser needs no round trip β€” which is the difference between an embedded image - * reappearing instantly and being downloaded again. Every document render asks for the same image at - * least twice (ProseMirror's own DOM, then the React node view) and every editor mounts twice (the - * read-only placeholder, then the live editor), so revalidating each time meant re-fetching the whole - * image on every open and reload β€” measured at ~1 MB per open on a real document, with the image area - * blank until it landed. `private` keeps it out of shared caches: the bytes are authorized per user. - * - * Anything else β€” a request that names the FILE, whose bytes move under it, or one whose object was - * rotated away mid-request β€” keeps revalidating. - */ -const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable' -const REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' - /** * GET /api/workspaces/[id]/files/inline?key=|fileId= * - * Serves an authenticated workspace-scoped image. Authentication and the - * `files.read_content` authorization check happen before resolving or reading - * the referenced object, preserving cross-workspace concealment. + * Serves an embedded workspace image. A URL naming the exact storage object that was streamed is + * cached immutably (see {@link ReadWorkspaceInlineFileResult.contentAddressed}): a content write + * never rewrites an object, and every document render asks for each image at least twice (the + * editor's DOM, then its React node view) across two editor mounts, so revalidating re-downloaded + * every image on every open. A URL naming the file, whose bytes move as it is edited, revalidates. */ export const GET = defineInternalBinaryRoute({ contract: getInlineWorkspaceFileContract, auth: internalSessionAuth, + headSafe: false, operation: readWorkspaceInlineFile.operation, rateLimit: internalRateLimits.none({ reason: 'Internal workspace inline image delivery' }), errorPolicy: internalFileErrorPolicies.inline, @@ -47,25 +32,12 @@ export const GET = defineInternalBinaryRoute({ fileId: query.fileId, }), useCase: readWorkspaceInlineFile, - present: ({ file, stream, contentAddressed }) => { - const secure = getSecureFileHeaders(file.name, file.type) - const headers = new Headers({ - 'Content-Type': secure.contentType, - 'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`, - 'Cache-Control': contentAddressed ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL, - 'X-Content-Type-Options': 'nosniff', - }) - if (secure.contentType === 'image/svg+xml') { - headers.set( - 'Content-Security-Policy', - "default-src 'none'; style-src 'unsafe-inline'; sandbox;" - ) - } - return { + present: ({ file, stream, contentAddressed }) => + presentFileDelivery({ body: stream, - contentType: secure.contentType, + filename: file.name, + contentType: file.type, contentLength: file.size, - headers, - } - }, + cacheControl: workspaceFileCacheControl(contentAddressed), + }), }) diff --git a/apps/sim/app/f/[token]/opengraph-image.tsx b/apps/sim/app/f/[token]/opengraph-image.tsx index db89ff9eff3..72c6b33f6cb 100644 --- a/apps/sim/app/f/[token]/opengraph-image.tsx +++ b/apps/sim/app/f/[token]/opengraph-image.tsx @@ -1,5 +1,5 @@ import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { readPublicFileSocialMetadata } from '@/lib/public-shares/api' import { buildProvenance } from '@/app/f/[token]/utils' export const dynamic = 'force-dynamic' @@ -14,16 +14,16 @@ export const size = COVER_OG_SIZE */ export default async function Image({ params }: { params: Promise<{ token: string }> }) { const { token } = await params - const resolved = await resolveActiveShareByToken(token) + const resolved = await readPublicFileSocialMetadata(token) - if (!resolved || resolved.share.authType !== 'public') { + if (!resolved || resolved.protected) { return createCoverOgImage({ title: 'Protected file', subtitle: 'Authentication is required to view this file', }) } - const { file, workspaceName, ownerName } = resolved + const { file, workspaceName, ownerName } = resolved.metadata return createCoverOgImage({ title: file.originalName, diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 8722e80b832..e0172687298 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -1,14 +1,9 @@ -import { cache } from 'react' import type { Metadata } from 'next' import { cookies } from 'next/headers' import { notFound } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { - deploymentAuthCookieName, - isEmailAllowed, - validateAuthToken, -} from '@/lib/core/security/deployment' -import { resolveActiveShareByToken } from '@/lib/public-shares/manager' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { publicFileShareCredential, readPublicFileSocialMetadata } from '@/lib/public-shares/api' +import { authorizePublicFileShare, readPublicFileShare } from '@/lib/public-shares/application' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { PublicFileAuth } from '@/app/f/[token]/public-file-auth' import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' @@ -19,9 +14,6 @@ import { getBrandConfig } from '@/ee/whitelabeling' export const dynamic = 'force-dynamic' -/** Deduped per-request so `generateMetadata` and the page share one DB resolve. */ -const resolveShare = cache(resolveActiveShareByToken) - /** Shared links must never be indexed by search engines. */ const NOINDEX = { index: false, follow: false } as const @@ -36,20 +28,21 @@ interface PublicFilePageProps { */ export async function generateMetadata({ params }: PublicFilePageProps): Promise { const { token } = await params - const resolved = await resolveShare(token) + const resolved = await readPublicFileSocialMetadata(token) if (!resolved) { return { robots: NOINDEX } } let title: string let description: string - if (resolved.share.authType !== 'public') { + if (resolved.protected) { title = 'Shared file' description = 'Authentication is required to view this file.' } else { - title = resolved.file.originalName + title = resolved.metadata.file.originalName description = - buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file Β· ${title}` + buildProvenance(resolved.metadata.workspaceName, resolved.metadata.ownerName) || + `Shared file Β· ${title}` } const brand = getBrandConfig() @@ -62,66 +55,35 @@ export async function generateMetadata({ params }: PublicFilePageProps): Promise } } -/** The auth-relevant slice of a resolved share row. */ -interface GateShare { - id: string - authType: string - password: string | null - allowedEmails: unknown -} - -/** - * Returns the auth prompt to render when a protected share is not yet authorized, - * or `null` when the visitor may view the file. `password`/`email` use the - * `file_auth_{shareId}` cookie; `sso` uses the global Sim session. - */ -async function renderAuthGate(token: string, share: GateShare) { - if (share.authType === 'public') return null - - if (share.authType === 'sso') { - const session = await getSession() - const allowedEmails = Array.isArray(share.allowedEmails) - ? (share.allowedEmails as string[]) - : [] - const authorized = Boolean( - session?.user?.email && isEmailAllowed(session.user.email, allowedEmails) - ) - return authorized ? null : - } - - const cookieStore = await cookies() - const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value - if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null - - return share.authType === 'email' ? ( - - ) : ( - - ) -} - export default async function PublicFilePage({ params }: PublicFilePageProps) { const { token } = await params - - const resolved = await resolveShare(token) - if (!resolved) { - notFound() + try { + const auth = await authorizePublicFileShare({ + token, + credential: await publicFileShareCredential((await cookies()).getAll()), + }) + if (!auth.authorized) { + if (auth.authType === 'sso') return + if (auth.authType === 'email') return + return + } + const { file, owner, workspaceName, ownerName } = await readPublicFileShare({ + grant: auth.grant, + }) + return ( + + ) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') notFound() + throw error } - - const { share, file, workspaceName, ownerName } = resolved - - const gate = await renderAuthGate(token, share) - if (gate) return gate - - return ( - - ) } diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 4b57b43f56d..11d67c49e73 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -6,6 +6,7 @@ import { Download } from '@sim/emcn/icons' import Link from 'next/link' import { SITE_URL } from '@/lib/core/utils/urls' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { buildProvenance } from '@/app/f/[token]/utils' import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -14,6 +15,7 @@ import { createPublicFileContentSource } from '@/hooks/use-file-content-source' interface PublicFileViewProps { token: string + owner: EditableFileOwner name: string type: string size: number @@ -25,6 +27,7 @@ interface PublicFileViewProps { export function PublicFileView({ token, + owner, name, type, size, @@ -43,10 +46,10 @@ export function PublicFileView({ // storage key + `updatedAt`) refetch when the shared file changes β€” even when its // size is unchanged. // Embedded images route through the token-scoped cascade endpoint, which serves them only when the - // shared document actually references them and they live in its workspace. + // shared document actually references them and they have the same canonical owner. const source = useMemo( - () => createPublicFileContentSource(token, contentUrl), - [token, contentUrl] + () => createPublicFileContentSource(token, contentUrl, owner), + [token, contentUrl, owner] ) const file = useMemo( () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx index c47b5077f43..29a40a09990 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx @@ -52,7 +52,7 @@ export function ChatPanelLayout({ {!collapsed && (
{ + if (e.button !== 0) return e.preventDefault() const el = mothershipRef.current @@ -285,6 +286,10 @@ export function useMothershipResize( const cancelPointer = (event: PointerEvent) => { if (event.pointerId === pointerId) cancel() } + const complete = () => finish(true) + const completePointer = (event: PointerEvent) => { + if (event.pointerId === pointerId) complete() + } cleanupRef.current = cancel handle.addEventListener( @@ -307,22 +312,14 @@ export function useMothershipResize( { signal } ) - handle.addEventListener( - 'pointerup', - (upEvent: PointerEvent) => { - if (upEvent.pointerId !== pointerId) return - finish(true) - }, - { signal } - ) + handle.addEventListener('pointerup', completePointer, { signal }) // Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.) // Without this, body cursor/userSelect and transition would be permanently stuck handle.addEventListener('pointercancel', cancelPointer, { signal }) - handle.addEventListener('lostpointercapture', cancelPointer, { signal }) - // A blur mid-drag (cmd-tab, window switch) would otherwise strand the - // body cursor/userSelect overrides with no pointerup coming - window.addEventListener('blur', cancel, { signal }) + // Capture or focus can be lost before pointerup reaches the handle; retain the user's resize. + handle.addEventListener('lostpointercapture', completePointer, { signal }) + window.addEventListener('blur', complete, { signal }) }, [desktopScopeId, rememberWidth] ) diff --git a/apps/sim/hooks/queries/public-shares.ts b/apps/sim/hooks/queries/public-shares.ts index e2552b12531..5f33d20d163 100644 --- a/apps/sim/hooks/queries/public-shares.ts +++ b/apps/sim/hooks/queries/public-shares.ts @@ -19,7 +19,7 @@ export const FILE_SHARE_STALE_TIME = 30 * 1000 /** * Query key factories for public shares */ -export const shareKeys = { +const shareKeys = { all: ['publicShares'] as const, details: () => [...shareKeys.all, 'detail'] as const, detail: (workspaceId: string, fileId: string) => diff --git a/apps/sim/hooks/use-file-content-source.tsx b/apps/sim/hooks/use-file-content-source.tsx index 4c342369166..f1586b8611b 100644 --- a/apps/sim/hooks/use-file-content-source.tsx +++ b/apps/sim/hooks/use-file-content-source.tsx @@ -3,9 +3,10 @@ import { createContext, useContext } from 'react' import { type EmbeddedFileRef, - extractEmbeddedFileRef, + resolveEmbeddedFileRef, storedFileId, } from '@/lib/uploads/utils/embedded-image-ref' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' export interface FileContentUrlOptions { /** Request the uncompiled source instead of the rendered/compiled bytes. */ @@ -85,14 +86,20 @@ function buildServeUrl( /** Build a source whose embeds resolve through `inlineBase` (the workspace- or token-scoped inline route). */ function inlineImageSource( buildUrl: FileContentSource['buildUrl'], - inlineBase: string + inlineBase: string, + owner?: EditableFileOwner ): FileContentSource { return { buildUrl, resolveImageSrc: (src) => { if (!src) return src - const ref = extractEmbeddedFileRef(src) - return ref ? `${inlineBase}?${inlineRefQuery(ref)}` : src + const resolved = resolveEmbeddedFileRef( + src, + owner, + typeof window === 'undefined' ? undefined : window.location.origin + ) + if (resolved.kind === 'rejected') return undefined + return resolved.kind === 'file' ? `${inlineBase}?${inlineRefQuery(resolved.reference)}` : src }, } } @@ -110,7 +117,8 @@ export function createWorkspaceFileContentSource( return { ...inlineImageSource( (key, opts) => buildServeUrl(key, opts, storageContext), - `/api/workspaces/${workspaceId}/files/inline` + `/api/workspaces/${workspaceId}/files/inline`, + { entityType: 'workspace', entityId: workspaceId } ), ...imageDimensions, } @@ -119,16 +127,18 @@ export function createWorkspaceFileContentSource( /** * Public share source. Direct file bytes come from the token content URL; embedded images route through * `/api/files/public/{token}/inline`, which serves them only when referenced by the shared document and - * in its workspace. + * in its canonical owner. */ export function createPublicFileContentSource( token: string, - contentUrl: string + contentUrl: string, + owner?: EditableFileOwner ): FileContentSource { return inlineImageSource( (_key, opts) => opts?.preview ? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1` : contentUrl, - `/api/files/public/${token}/inline` + `/api/files/public/${token}/inline`, + owner ) } diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index 4f21b2a9db6..d361721efba 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -414,4 +414,9 @@ export async function register() { const { startServiceUsageReplay } = await import('./lib/mothership/billing/service-delivery') startServiceUsageReplay() + + const { startAbandonedRunTakeover } = await import( + '@/lib/mothership/request/application/abandoned-runs' + ) + startAbandonedRunTakeover() } diff --git a/apps/sim/lib/api/contracts/file-copy-input.ts b/apps/sim/lib/api/contracts/file-copy-input.ts new file mode 100644 index 00000000000..b032fafac43 --- /dev/null +++ b/apps/sim/lib/api/contracts/file-copy-input.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const copyIdSchema = z.string().trim().min(1).max(200) +const copyOwnerSchema = z.strictObject({ + entityType: z.enum(['workspace', 'project']).describe('Resource scope that owns these files.'), + entityId: z.string().min(1).max(200).describe('Identifier of the owning workspace or Project.'), +}) +const copyIdsSchema = z + .array(copyIdSchema) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many selected file items') + .refine((ids) => new Set(ids).size === ids.length, 'Selected identifiers must be unique') + .default([]) + +export const fileCopySourceSchema = z + .strictObject({ + owner: copyOwnerSchema.describe('Owner from which to read the selected files and folders.'), + fileIds: copyIdsSchema.describe('Identifiers of individual files to copy.'), + folderIds: copyIdsSchema.describe( + 'Identifiers of folders to copy with their active descendants and files.' + ), + }) + .refine((source) => source.fileIds.length + source.folderIds.length > 0, { + message: 'Select at least one file or folder to copy', + }) +export type FileCopySource = z.output + +export const fileCopyDestinationSchema = z.strictObject({ + owner: copyOwnerSchema.describe('Owner in which to create the copied files and folders.'), + folderId: copyIdSchema + .nullable() + .default(null) + .describe('Existing destination folder identifier. Omit or use null for the owner root.'), +}) +export type FileCopyDestination = z.output + +export const fileCopyInputSchema = z.strictObject({ + source: fileCopySourceSchema.describe('Selection to read under the source owner.'), + destination: fileCopyDestinationSchema.describe('Destination requiring file write access.'), +}) +export type FileCopyInput = z.output diff --git a/apps/sim/lib/api/contracts/file-copy.ts b/apps/sim/lib/api/contracts/file-copy.ts new file mode 100644 index 00000000000..3912a4fcf56 --- /dev/null +++ b/apps/sim/lib/api/contracts/file-copy.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' +import { fileCopyDestinationSchema, fileCopyInputSchema } from '@/lib/api/contracts/file-copy-input' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { projectFileFolderRecordSchema } from '@/lib/api/contracts/project-file-folders' +import { projectFileRecordSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const copyFileItemsBodySchema = fileCopyInputSchema +export type CopyFileItemsBody = z.input + +export const copiedFileSchema = projectFileRecordSchema + .omit({ key: true, path: true, url: true, share: true }) + .extend({ + id: projectFileRecordSchema.shape.id.describe('Identifier of the new file.'), + name: projectFileRecordSchema.shape.name.describe( + 'Name of the copied file, including its extension.' + ), + size: projectFileRecordSchema.shape.size.describe('Current content size in bytes.'), + type: projectFileRecordSchema.shape.type.describe('MIME type of the copied file.'), + width: projectFileRecordSchema.shape.width.describe( + 'Known image width in pixels, when available.' + ), + height: projectFileRecordSchema.shape.height.describe( + 'Known image height in pixels, when available.' + ), + uploadedBy: projectFileRecordSchema.shape.uploadedBy.describe( + 'User who performed the copy, when available.' + ), + folderId: projectFileRecordSchema.shape.folderId.describe( + 'Containing folder identifier, or null at the owner root.' + ), + folderPath: projectFileRecordSchema.shape.folderPath.describe( + 'Containing folder path, or null at the owner root.' + ), + deletedAt: z.iso.datetime().nullable().describe('Archive time, or null for an active file.'), + uploadedAt: z.iso.datetime().describe('Time the new file was created.'), + updatedAt: z.iso.datetime().describe('Time the file metadata last changed.'), + contentUpdatedAt: z.iso + .datetime() + .nullable() + .describe('Time the current file content was written.'), + owner: fileCopyDestinationSchema.shape.owner.describe( + 'Canonical destination owner of the copied file.' + ), + revision: nonEmptyIdSchema.describe('Current content revision for subsequent edits.'), + }) +export type CopiedFile = z.output + +export const copiedFileFolderSchema = projectFileFolderRecordSchema.extend({ + id: projectFileFolderRecordSchema.shape.id.describe('Identifier of the new folder.'), + userId: projectFileFolderRecordSchema.shape.userId.describe( + 'User who performed the copy, when available.' + ), + name: projectFileFolderRecordSchema.shape.name.describe('Name of the copied folder.'), + parentId: projectFileFolderRecordSchema.shape.parentId.describe( + 'Parent folder identifier, or null at the owner root.' + ), + path: projectFileFolderRecordSchema.shape.path.describe( + 'Path of the copied folder within its owner.' + ), + sortOrder: projectFileFolderRecordSchema.shape.sortOrder.describe( + 'Display ordering value within the parent folder.' + ), + deletedAt: z.iso.datetime().nullable().describe('Archive time, or null for an active folder.'), + createdAt: z.iso.datetime().describe('Time the new folder was created.'), + updatedAt: z.iso.datetime().describe('Time the folder metadata last changed.'), + owner: fileCopyDestinationSchema.shape.owner.describe( + 'Canonical destination owner of the copied folder.' + ), +}) +export type CopiedFileFolder = z.output + +export const copyFileItemsResponseSchema = z.object({ + files: z.array(copiedFileSchema).describe('Created files with new identities.'), + folders: z.array(copiedFileFolderSchema).describe('Created folders in their new hierarchy.'), +}) +export type CopyFileItemsResponse = z.output + +export const copyFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/files/copy', + query: noInputSchema, + body: copyFileItemsBodySchema, + response: { mode: 'json', schema: copyFileItemsResponseSchema, status: 201 }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-downloads.ts b/apps/sim/lib/api/contracts/project-file-downloads.ts new file mode 100644 index 00000000000..4914f5ff464 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-downloads.ts @@ -0,0 +1,34 @@ +import type { z } from 'zod' +import { + projectFileParamsSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { downloadWorkspaceFileItemsQuerySchema } from '@/lib/api/contracts/workspace-file-folders' +import { exportWorkspaceFileSnapshotBodySchema } from '@/lib/api/contracts/workspace-files' + +export const downloadProjectFileItemsQuerySchema = downloadWorkspaceFileItemsQuerySchema.strict() +export type DownloadProjectFileItemsQuery = z.output +export const downloadProjectFileItemsContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/download', + params: projectFilesParamsSchema, + query: downloadProjectFileItemsQuerySchema, + response: { mode: 'binary' }, +}) + +/** + * The proxy accepts at most 10 MiB of encoded JSON, independently of the decoded Markdown cap. + * Heavily escaped snapshots can hit this wire limit before their decoded-content limit. + */ +export const MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES = 10 * 1024 * 1024 + +export const exportProjectFileSnapshotBodySchema = exportWorkspaceFileSnapshotBodySchema.strict() +export type ExportProjectFileSnapshotBody = z.input +export const exportProjectFileSnapshotContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/export', + params: projectFileParamsSchema, + body: exportProjectFileSnapshotBodySchema, + response: { mode: 'binary' }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-extraction.ts b/apps/sim/lib/api/contracts/project-file-extraction.ts new file mode 100644 index 00000000000..456f40ff8db --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-extraction.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { projectFileParamsSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { extractWorkspaceFileResponseSchema } from '@/lib/api/contracts/workspace-files' + +export const extractProjectFileResponseSchema = extractWorkspaceFileResponseSchema.extend({ + folderId: nonEmptyIdSchema, + folderDisplayPath: z.string(), +}) +export type ExtractProjectFileResponse = z.output + +export const extractProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/extract', + params: projectFileParamsSchema, + response: { mode: 'json', schema: extractProjectFileResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-shares.ts b/apps/sim/lib/api/contracts/project-file-shares.ts new file mode 100644 index 00000000000..8c8c8f7c618 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-shares.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { + projectFileCapabilitiesSchema, + projectFileParamsSchema, +} from '@/lib/api/contracts/project-files' +import { + shareAuthTypeSchema, + shareRecordSchema, + upsertFileShareBodySchema, +} from '@/lib/api/contracts/public-shares' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const projectFileSharePolicySchema = z.object({ + canPublish: z + .boolean() + .describe( + 'Whether current Project sharing policies permit publishing or updating an active share.' + ), + allowedAuthTypes: z + .array(shareAuthTypeSchema) + .max(4) + .describe('Authentication modes permitted by every applicable active environment policy.'), +}) +export type ProjectFileSharePolicy = z.output +export const getProjectFileShareResponseSchema = z.object({ + share: shareRecordSchema.nullable(), + policy: projectFileSharePolicySchema, + capabilities: projectFileCapabilitiesSchema, +}) +export type GetProjectFileShareResponse = z.output +export const updateProjectFileShareBodySchema = upsertFileShareBodySchema +export type UpdateProjectFileShareBody = z.input +export const updateProjectFileShareResponseSchema = z.object({ share: shareRecordSchema }) +export type UpdateProjectFileShareResponse = z.output +export const getProjectFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/share', + params: projectFileParamsSchema, + response: { mode: 'json', schema: getProjectFileShareResponseSchema }, +}) +export const updateProjectFileShareContract = defineRouteContract({ + method: 'PUT', + path: '/api/projects/[id]/files/[fileId]/share', + params: projectFileParamsSchema, + body: updateProjectFileShareBodySchema, + response: { mode: 'json', schema: updateProjectFileShareResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/public-shares.ts b/apps/sim/lib/api/contracts/public-shares.ts index 84a6362fc69..4fd88574d3b 100644 --- a/apps/sim/lib/api/contracts/public-shares.ts +++ b/apps/sim/lib/api/contracts/public-shares.ts @@ -65,8 +65,6 @@ const getFileShareResponseSchema = z.object({ share: shareRecordSchema.nullable(), }) -export type GetFileShareResponse = z.output - export const getFileShareContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/files/[fileId]/share', @@ -138,7 +136,7 @@ export const getPublicFileContentContract = defineRouteContract({ /** * Binary stream of an image embedded in a shared document. Authorized by the parent * document's active share β€” the route serves the bytes only when the reference is - * actually embedded in the shared document AND the file lives in the same workspace, + * actually embedded in the shared document AND the file has the same canonical owner, * and only when the bytes are a renderable raster image. */ export const getPublicInlineFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/file-copy.ts b/apps/sim/lib/api/contracts/v2/file-copy.ts new file mode 100644 index 00000000000..65adf70f339 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/file-copy.ts @@ -0,0 +1,12 @@ +import { copyFileItemsBodySchema, copyFileItemsResponseSchema } from '@/lib/api/contracts/file-copy' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2CopyFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/copy', + query: noInputSchema, + body: copyFileItemsBodySchema, + response: { mode: 'json', schema: v2DataResponse(copyFileItemsResponseSchema), status: 201 }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 323fcaf36f7..113bad59775 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1070,7 +1070,7 @@ export const v2MoveFileItemsContract = defineRouteContract({ * Comma-separated only: v2 rejects a query parameter sent more than once, so a * repeated-parameter form would never reach this schema. */ -function v2QuerySelectionListSchema(field: string) { +export function v2QuerySelectionListSchema(field: string) { return z .string() .optional() diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index fe19ef283db..792a3b3ef8f 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -1,4 +1,5 @@ import { v2GetAuditLogContract, v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy' import { v2DeleteFileVersionContract, v2DownloadFileVersionContract, @@ -63,6 +64,7 @@ import { type OpenApiSuccessMetadata, } from '@/lib/api/openapi/types' import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' @@ -153,6 +155,35 @@ function auditOperation( } } +export const unpublishedFileCopyOpenApiRoutes = [ + defineOpenApiRoute( + v2CopyFileItemsContract, + filesOperation({ + applicationOperation: fileCopyOperation, + operationId: 'copyFileItems', + summary: 'Copy File Items', + description: `Copy selected files and folder trees between workspace or Project owners. Source read and destination write access are checked independently. Copies receive new identities and retain source secret provenance. Registration is atomic, with destination names resolved by the existing copy rules. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { description: 'New file and folder identities with their destination owner.' }, + }), + { + query: v2CopyFileItemsContract.query, + body: documentedSchema( + v2CopyFileItemsContract.body, + 'CopyFileItemsRequest', + 'Copy file items request', + 'Exact source owner and selected identifiers, plus the destination owner and folder.' + ), + response: documentedSchema( + v2CopyFileItemsContract.response.schema, + 'V2CopyFileItemsResponse', + 'Copy file items response', + 'Created files and folders, without private storage keys.' + ), + } + ), +] + const declaredRoutes = [ defineOpenApiRoute( v2ListFilesContract, diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-downloads.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-downloads.ts new file mode 100644 index 00000000000..187d91c73b9 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-downloads.ts @@ -0,0 +1,78 @@ +import { + documentedSchema, + HEAD_MIRRORS_GET, + HEAD_OMITS_PAYLOAD_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2DownloadProjectFileItemsContract, + v2ExportProjectFileSnapshotContract, +} from '@/lib/api/contracts/v2/project-file-downloads' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' + +export const projectFileDownloadOpenApiRoutes = [ + defineOpenApiRoute( + v2DownloadProjectFileItemsContract, + { + applicationOperation: projectFileOperations.downloadItems, + operationId: 'downloadProjectFileItems', + summary: 'Download Project File Items', + description: `Download selected files and recursive folder contents as one ZIP archive. Duplicate selections are included once. Select at most ${MAX_ZIP_DOWNLOAD_FILES} files in total; archive bytes are bounded. Unknown or archived selections are rejected. Downloads record an audit event. ${WORKSPACE_API_KEY_DENIED} ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Selected files in their Project folder paths.', + headers: ['Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['application/zip'], + }, + }, + { + params: documentedSchema( + v2DownloadProjectFileItemsContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2DownloadProjectFileItemsContract.query, + 'DownloadProjectFileItemsQuery', + 'Project archive selection', + 'File and folder identifiers within the Project.' + ), + } + ), + defineOpenApiRoute( + v2ExportProjectFileSnapshotContract, + { + applicationOperation: projectFileOperations.exportSnapshot, + operationId: 'exportProjectFileSnapshot', + summary: 'Export Project File Snapshot', + description: `Export the supplied visible Markdown snapshot without changing the stored file or its history. Readable embedded Project assets are bundled in a ZIP; a snapshot without bundled assets is returned as Markdown. Missing or unreadable assets remain as references. Total bytes are bounded. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Markdown snapshot or its archive with embedded assets.', + headers: ['Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['text/markdown', 'application/zip'], + }, + }, + { + params: documentedSchema( + v2ExportProjectFileSnapshotContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2ExportProjectFileSnapshotContract.query, + body: documentedSchema( + v2ExportProjectFileSnapshotContract.body, + 'ExportProjectFileSnapshotRequest', + 'Project Markdown snapshot', + 'The visible document content to export.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts new file mode 100644 index 00000000000..b8dde906cc4 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts @@ -0,0 +1,75 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2GetProjectFileShareContract, + v2UpdateProjectFileShareContract, +} from '@/lib/api/contracts/v2/project-file-shares' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileShareOpenApiRoutes = [ + defineOpenApiRoute( + v2GetProjectFileShareContract, + { + applicationOperation: projectFileOperations.readShare, + operationId: 'getProjectFileShare', + summary: 'Get Project File Share', + description: `Get a Project file's public-share configuration. An unshared file returns data: null; a disabled share retains its configuration with isActive: false. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'Current nullable file-share state.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileShareContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2GetProjectFileShareContract.query, + response: documentedSchema( + v2GetProjectFileShareContract.response.schema, + 'V2GetProjectFileShareResponse', + 'Get Project file share response', + 'Current public-share state for a Project file.' + ), + } + ), + defineOpenApiRoute( + v2UpdateProjectFileShareContract, + { + applicationOperation: projectFileOperations.updateShare, + operationId: 'updateProjectFileShare', + summary: 'Update Project File Share', + description: `Create or update a Project file's public share. isActive is required; omitted settings retain their current values except credentials unused by the selected access mode, which are cleared. Disabling retains the token and access configuration. Publication requires Project edit access and the current sharing policy across accessible active environments. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'The updated Project file share.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdateProjectFileShareContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2UpdateProjectFileShareContract.query, + body: documentedSchema( + v2UpdateProjectFileShareContract.body, + 'UpdateProjectFileShareRequest', + 'Update Project file share request', + 'Desired public-share state and access policy. Share tokens are generated by the server.' + ), + response: documentedSchema( + v2UpdateProjectFileShareContract.response.schema, + 'V2UpdateProjectFileShareResponse', + 'Update Project file share response', + 'Updated public-share state for a Project file.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-files.ts b/apps/sim/lib/api/contracts/v2/openapi/project-files.ts index 4af74a6231e..3588768689b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/project-files.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/project-files.ts @@ -5,12 +5,14 @@ import { RESOURCE_ERRORS, WORKSPACE_API_KEY_DENIED, } from '@/lib/api/contracts/v2/openapi/shared' +import { v2UnzipProjectFileContract } from '@/lib/api/contracts/v2/project-file-extraction' import { v2ArchiveProjectFileItemsContract, v2MoveProjectFileItemsContract, v2RenameProjectFileContract, v2RestoreProjectFileContract, } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { v2SearchProjectFileContentContract } from '@/lib/api/contracts/v2/project-file-search' import { v2CreateProjectFileContract, v2GetProjectFileMetadataContract, @@ -22,6 +24,71 @@ import { defineOpenApiRoute } from '@/lib/api/openapi/types' import { projectFileOperations } from '@/lib/projects/files/application/operations' export const projectFileOpenApiRoutes = [ + defineOpenApiRoute( + v2UnzipProjectFileContract, + { + applicationOperation: projectFileOperations.extractArchive, + operationId: 'unzipProjectFile', + summary: 'Unzip Project File', + description: `Extract a ZIP archive into a new sibling folder in the same Project. Use List Project Files to inspect its contents. Concurrent extraction of the same archive returns \`409\`; size or processing-time limits return \`413\`. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Counts and destination folder for the unpacked archive.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2UnzipProjectFileContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2UnzipProjectFileContract.query, + response: documentedSchema( + v2UnzipProjectFileContract.response.schema, + 'V2ProjectFileUnzipResponse', + 'Unzip Project file response', + 'Counts and destination folder for the unpacked archive.' + ), + } + ), + defineOpenApiRoute( + v2SearchProjectFileContentContract, + { + applicationOperation: projectFileOperations.searchContent, + operationId: 'searchProjectFileContent', + summary: 'Search Project File Content', + description: `Search indexed text in active Project files, returning matching lines with file IDs and line numbers. Folder filters narrow both results and reported coverage. Missing matches are inconclusive when complete is false, or skippedFiles or partialFiles is nonzero. truncated means additional matches exist beyond maxResults. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'Locked'], + success: { + description: 'Matching lines and the index coverage they were drawn from.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2SearchProjectFileContentContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2SearchProjectFileContentContract.query, + 'SearchProjectFileContentQuery', + 'Project file search query', + 'The search text, mode, result limit, and optional folder scope.' + ), + response: documentedSchema( + v2SearchProjectFileContentContract.response.schema, + 'V2ProjectFileSearchResultsResponse', + 'File search results response', + 'Matching lines from indexed file content.' + ), + } + ), defineOpenApiRoute( v2RenameProjectFileContract, { diff --git a/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts b/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts index b4f2c8acd91..d40f695b630 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/unpublished-project-files.ts @@ -1,4 +1,7 @@ +import { unpublishedFileCopyOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/files-audit' +import { projectFileDownloadOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-downloads' import { projectFileFolderOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-folders' +import { projectFileShareOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-shares' import { projectFileUploadOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-uploads' import { projectFileVersionOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-versions' import { projectFileOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-files' @@ -8,9 +11,12 @@ export const unpublishedProjectFileDocumentation = { reason: 'Project files are unreleased. Publish these routes and the Projects CLI documentation together at launch.', routes: [ + ...unpublishedFileCopyOpenApiRoutes, + ...projectFileDownloadOpenApiRoutes, ...projectFileOpenApiRoutes, ...projectFileVersionOpenApiRoutes, ...projectFileFolderOpenApiRoutes, ...projectFileUploadOpenApiRoutes, + ...projectFileShareOpenApiRoutes, ], } diff --git a/apps/sim/lib/api/contracts/v2/project-file-downloads.ts b/apps/sim/lib/api/contracts/v2/project-file-downloads.ts new file mode 100644 index 00000000000..57033617102 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-downloads.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { exportProjectFileSnapshotBodySchema } from '@/lib/api/contracts/project-file-downloads' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2QuerySelectionListSchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileParamsSchema, + v2ProjectFilesParamsSchema, +} from '@/lib/api/contracts/v2/project-files' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' + +export const v2DownloadProjectFileItemsQuerySchema = z + .object({ + fileIds: v2QuerySelectionListSchema('fileIds').describe( + `File identifiers to include, comma-separated. At most ${MAX_ZIP_DOWNLOAD_FILES} entries.` + ), + folderIds: v2QuerySelectionListSchema('folderIds').describe( + `Folder identifiers to include recursively, comma-separated. The resolved selection allows at most ${MAX_ZIP_DOWNLOAD_FILES} files.` + ), + }) + .strict() +export type V2DownloadProjectFileItemsQuery = z.output + +export const v2DownloadProjectFileItemsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/bulk-download', + params: v2ProjectFilesParamsSchema, + query: v2DownloadProjectFileItemsQuerySchema, + response: { mode: 'binary' }, +}) + +export const v2ExportProjectFileSnapshotBodySchema = exportProjectFileSnapshotBodySchema.extend({ + content: exportProjectFileSnapshotBodySchema.shape.content.describe( + 'Visible Markdown snapshot to export. This does not replace the stored file or create a version.' + ), +}) +export type V2ExportProjectFileSnapshotBody = z.input + +export const v2ExportProjectFileSnapshotContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/export', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2ExportProjectFileSnapshotBodySchema, + response: { mode: 'binary' }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-extraction.ts b/apps/sim/lib/api/contracts/v2/project-file-extraction.ts new file mode 100644 index 00000000000..afae040004e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-extraction.ts @@ -0,0 +1,21 @@ +import type { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2UnzipFileDataSchema } from '@/lib/api/contracts/v2/files' +import { v2ProjectFileParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2UnzipProjectFileDataSchema = v2UnzipFileDataSchema.meta({ + id: 'V2ProjectFileUnzipResult', + title: 'Project unzip result', + description: 'Outcome of unzipping a Project archive into a sibling folder.', +}) +export type V2ProjectFileUnzipResult = z.output + +export const v2UnzipProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/unzip', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2UnzipProjectFileDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-search.ts b/apps/sim/lib/api/contracts/v2/project-file-search.ts new file mode 100644 index 00000000000..3e4a5716b41 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-search.ts @@ -0,0 +1,23 @@ +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2FileSearchResultsSchema, + v2SearchFileContentQuerySchema, +} from '@/lib/api/contracts/v2/files' +import { v2ProjectFilesParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +const v2SearchProjectFileContentQuerySchema = v2SearchFileContentQuerySchema + .omit({ workspaceId: true }) + .extend({ + folderPaths: v2SearchFileContentQuerySchema.shape.folderPaths.describe( + 'Comma-separated folder paths within the Project. Omit to search the entire Project; index coverage applies to the selected folders.' + ), + }) + +export const v2SearchProjectFileContentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/search', + params: v2ProjectFilesParamsSchema, + query: v2SearchProjectFileContentQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileSearchResultsSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-shares.ts b/apps/sim/lib/api/contracts/v2/project-file-shares.ts new file mode 100644 index 00000000000..d5dda56f280 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-shares.ts @@ -0,0 +1,39 @@ +import type { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { sharePasswordSchema } from '@/lib/api/contracts/public-shares' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2FileShareSchema, + v2NullableFileShareSchema, + v2UpsertFileShareBodySchema, +} from '@/lib/api/contracts/v2/files' +import { v2ProjectFileParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2UpdateProjectFileShareBodySchema = v2UpsertFileShareBodySchema + .omit({ workspaceId: true }) + .extend({ + password: sharePasswordSchema + .optional() + .describe( + 'Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected.' + ), + }) +export type V2UpdateProjectFileShareBody = z.input + +export const v2GetProjectFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2NullableFileShareSchema) }, +}) + +export const v2UpdateProjectFileShareContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2UpdateProjectFileShareBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileShareSchema) }, +}) diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index ef65bd3c03e..173f838e7fc 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -138,6 +138,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/files/bulk-download', load: () => import('@/app/api/v2/files/bulk-download/route'), }, + { + pattern: '/api/v2/files/copy', + load: () => import('@/app/api/v2/files/copy/route'), + }, { pattern: '/api/v2/files/folders', load: () => import('@/app/api/v2/files/folders/route'), @@ -474,6 +478,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/projects/{projectId}/files/{fileId}/content', load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/content/route'), }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/export', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/export/route'), + }, { pattern: '/api/v2/projects/{projectId}/files/{fileId}/metadata', load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route'), @@ -482,6 +490,14 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/projects/{projectId}/files/{fileId}/restore', load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/restore/route'), }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/share', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/share/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/unzip', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route'), + }, { pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions', load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/route'), @@ -504,6 +520,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/projects/{projectId}/files/archive', load: () => import('@/app/api/v2/projects/[projectId]/files/archive/route'), }, + { + pattern: '/api/v2/projects/{projectId}/files/bulk-download', + load: () => import('@/app/api/v2/projects/[projectId]/files/bulk-download/route'), + }, { pattern: '/api/v2/projects/{projectId}/files/folders', load: () => import('@/app/api/v2/projects/[projectId]/files/folders/route'), @@ -520,6 +540,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/projects/{projectId}/files/move', load: () => import('@/app/api/v2/projects/[projectId]/files/move/route'), }, + { + pattern: '/api/v2/projects/{projectId}/files/search', + load: () => import('@/app/api/v2/projects/[projectId]/files/search/route'), + }, { pattern: '/api/v2/projects/{projectId}/files/uploads', load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/route'), diff --git a/apps/sim/lib/core/application/resource-delegation.ts b/apps/sim/lib/core/application/resource-delegation.ts index 693aecb52c8..4956229fda6 100644 --- a/apps/sim/lib/core/application/resource-delegation.ts +++ b/apps/sim/lib/core/application/resource-delegation.ts @@ -38,7 +38,7 @@ function isSelectionIds(value: unknown): value is string[] { } /** Validates the paired scope shared by delegation admission and the copy application boundary. */ -function isResourceFileCopyScope(scope: unknown): scope is ResourceFileCopyScope { +export function isResourceFileCopyScope(scope: unknown): scope is ResourceFileCopyScope { if (!isRecordLike(scope) || scope.kind !== 'file_copy') return false const { source, destination } = scope return ( diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 2f1a4a82d16..9a58894564e 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -120,6 +120,13 @@ const FEATURE_FLAGS = { 'targeting. Off-AppConfig falls back to KNOWLEDGE_MEMBER_ACCESS.', fallback: 'KNOWLEDGE_MEMBER_ACCESS', }, + 'mothership-abandoned-run-takeover-disabled': { + description: + 'Kill switch for Sim taking over Chat runs whose controller died (a deploy or a crash) and ' + + 're-running their interrupted tool. Global on/off only; on stops every takeover scan. ' + + 'Off by default, so takeovers run unless this is turned on.', + fallback: null, + }, } satisfies Record /** diff --git a/apps/sim/lib/core/security/deployment-auth.ts b/apps/sim/lib/core/security/deployment-auth.ts index 23e94fdcdb1..d178946c0ba 100644 --- a/apps/sim/lib/core/security/deployment-auth.ts +++ b/apps/sim/lib/core/security/deployment-auth.ts @@ -1,76 +1,20 @@ import { createLogger } from '@sim/logger' -import { safeCompare } from '@sim/security/compare' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' -import type { TokenBucketConfig } from '@/lib/core/rate-limiter' -import { RateLimiter } from '@/lib/core/rate-limiter' import { type DeploymentAuthKind, type DeploymentAuthResource, deploymentAuthCookieName, - isEmailAllowed, - readDeploymentAuthToken, } from '@/lib/core/security/deployment' -import { decryptSecret } from '@/lib/core/security/encryption' +import { + type DeploymentAuthBody, + type DeploymentAuthResult, + validateDeploymentCredentials, +} from '@/lib/core/security/deployment-credentials' import { getClientIp } from '@/lib/core/utils/request' const logger = createLogger('DeploymentAuth') -const rateLimiter = new RateLimiter() - -/** - * Throttles unauthenticated password guesses per client IP against a single - * deployment, mirroring the OTP/SSO IP limits. - */ -const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 10, - refillRate: 10, - refillIntervalMs: 15 * 60_000, -} - -/** - * Caps guesses against one resource independently of client identity. This is - * the backstop for distributed attempts and for requests whose proxy chain - * cannot be resolved safely. - */ -const PASSWORD_RESOURCE_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 15 * 60_000, -} - -function passwordRateLimitResult( - retryAfterMs: number | undefined, - fallbackMs: number -): DeploymentAuthResult { - return { - authorized: false, - error: 'Too many attempts. Please try again later.', - status: 429, - retryAfterMs: retryAfterMs ?? fallbackMs, - } -} - -export interface DeploymentAuthBody { - password?: string - email?: string - input?: unknown -} - -export interface DeploymentAuthResult { - authorized: boolean - authenticatedEmail?: string - error?: string - status?: number - retryAfterMs?: number -} - -/** - * Shared password/email/SSO gate for deployed resources. The `cookiePrefix` - * selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit - * namespace so chat deployments and public file shares share one code path. Both - * support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`. - */ +/** Adapts authenticated session identity and HTTP credentials to the common deployment gate. */ export async function validateDeploymentAuth( requestId: string, resource: DeploymentAuthResource, @@ -78,153 +22,30 @@ export async function validateDeploymentAuth( parsedBody: DeploymentAuthBody | null | undefined, cookiePrefix: DeploymentAuthKind ): Promise { - const authType = resource.authType || 'public' - - if (authType === 'public') { - return { authorized: true } - } - - if (authType === 'password' || authType === 'email') { - const authCookie = request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id)) - - if (authCookie) { - const claims = await readDeploymentAuthToken({ token: authCookie.value, resource }) - if (claims) return { authorized: true, ...claims } - } - } - - if (authType === 'password') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_password' } - } - + let sessionEmail: string | null | undefined + let sessionPresent = false + if (resource.authType === 'sso' && (request.method === 'GET' || parsedBody)) { try { - if (!parsedBody) { - return { authorized: false, error: 'Password is required' } - } - - const { password, input } = parsedBody - - if (input && !password) { - return { authorized: false, error: 'auth_required_password' } - } - - if (!password) { - return { authorized: false, error: 'Password is required' } - } - - if (!resource.password) { - logger.error(`[${requestId}] No password set for password-protected ${resource.id}`) - return { authorized: false, error: 'Authentication configuration error' } - } - - const ip = getClientIp(request) - if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `${cookiePrefix}-password:ip:${resource.id}:${ip}`, - PASSWORD_IP_RATE_LIMIT, - { failClosed: true } - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, { - resourceId: resource.id, - cookiePrefix, - ip, - }) - return passwordRateLimitResult( - ipRateLimit.retryAfterMs, - PASSWORD_IP_RATE_LIMIT.refillIntervalMs - ) - } - } - - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `${cookiePrefix}-password:resource:${resource.id}`, - PASSWORD_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, { - resourceId: resource.id, - cookiePrefix, - }) - return passwordRateLimitResult( - resourceRateLimit.retryAfterMs, - PASSWORD_RESOURCE_RATE_LIMIT.refillIntervalMs - ) - } - - const { decrypted } = await decryptSecret(resource.password) - if (!safeCompare(password, decrypted)) { - return { authorized: false, error: 'Invalid password' } - } - - return { authorized: true } - } catch (error) { - logger.error(`[${requestId}] Error validating password:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'email') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_email' } - } - - try { - if (!parsedBody) { - return { authorized: false, error: 'Email is required' } - } - - const { email, input } = parsedBody - - if (input && !email) { - return { authorized: false, error: 'auth_required_email' } - } - - if (!email) { - return { authorized: false, error: 'Email is required' } - } - - if (isEmailAllowed(email, resource.allowedEmails)) { - return { authorized: false, error: 'otp_required' } - } - - return { authorized: false, error: 'Email not authorized' } - } catch (error) { - logger.error(`[${requestId}] Error validating email:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'sso') { - try { - if (request.method !== 'GET' && !parsedBody) { - return { authorized: false, error: 'SSO authentication is required' } - } - const { getSession } = await import('@/lib/auth') const session = await getSession() - - if (!session || !session.user) { - return { authorized: false, error: 'auth_required_sso' } - } - - const userEmail = session.user.email - if (!userEmail) { - return { authorized: false, error: 'SSO session does not contain email' } - } - - if (isEmailAllowed(userEmail, resource.allowedEmails)) { - return { authorized: true, authenticatedEmail: normalizeEmail(userEmail) } - } - - return { authorized: false, error: 'Your email is not authorized to access this resource' } + sessionPresent = Boolean(session?.user) + sessionEmail = session?.user?.email } catch (error) { logger.error(`[${requestId}] Error validating SSO:`, error) return { authorized: false, error: 'SSO authentication error' } } } - - return { authorized: false, error: 'Unsupported authentication type' } + return validateDeploymentCredentials( + requestId, + resource, + { + method: request.method, + authToken: request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id))?.value, + clientIp: getClientIp(request), + sessionPresent, + sessionEmail, + }, + parsedBody, + cookiePrefix + ) } diff --git a/apps/sim/lib/core/security/deployment-credentials.ts b/apps/sim/lib/core/security/deployment-credentials.ts new file mode 100644 index 00000000000..1faa9f9858e --- /dev/null +++ b/apps/sim/lib/core/security/deployment-credentials.ts @@ -0,0 +1,233 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { normalizeEmail } from '@sim/utils/string' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { + type DeploymentAuthKind, + type DeploymentAuthResource, + isEmailAllowed, + readDeploymentAuthToken, +} from '@/lib/core/security/deployment' +import { decryptSecret } from '@/lib/core/security/encryption' + +const logger = createLogger('DeploymentAuth') + +const rateLimiter = new RateLimiter() + +/** + * Throttles unauthenticated password guesses per client IP against a single + * deployment, mirroring the OTP/SSO IP limits. + */ +const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + +/** + * Caps guesses against one resource independently of client identity. This is + * the backstop for distributed attempts and for requests whose proxy chain + * cannot be resolved safely. + */ +const PASSWORD_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} + +function passwordRateLimitResult( + retryAfterMs: number | undefined, + fallbackMs: number +): DeploymentAuthResult { + return { + authorized: false, + error: 'Too many attempts. Please try again later.', + status: 429, + retryAfterMs: retryAfterMs ?? fallbackMs, + } +} + +export interface DeploymentAuthBody { + password?: string + email?: string + input?: unknown +} + +export interface DeploymentAuthResult { + authorized: boolean + authenticatedEmail?: string + error?: string + status?: number + retryAfterMs?: number +} + +/** Verified session context and raw request credentials; callers obtain session identity from authentication. */ +export interface DeploymentCredentialContext { + method: string + authToken?: string + clientIp?: string | null + sessionPresent?: boolean + sessionEmail?: string | null +} + +/** + * Shared password/email/SSO gate for deployed resources. The `cookiePrefix` + * selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit + * namespace so chat deployments and public file shares share one code path. Both + * support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`. + */ +export async function validateDeploymentCredentials( + requestId: string, + resource: DeploymentAuthResource, + request: DeploymentCredentialContext, + parsedBody: DeploymentAuthBody | null | undefined, + cookiePrefix: DeploymentAuthKind +): Promise { + const authType = resource.authType || 'public' + + if (authType === 'public') { + return { authorized: true } + } + + if (authType === 'password' || authType === 'email') { + const authCookie = request.authToken + + if (authCookie) { + const claims = await readDeploymentAuthToken({ token: authCookie, resource }) + if (claims) return { authorized: true, ...claims } + } + } + + if (authType === 'password') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_password' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Password is required' } + } + + const { password, input } = parsedBody + + if (input && !password) { + return { authorized: false, error: 'auth_required_password' } + } + + if (!password) { + return { authorized: false, error: 'Password is required' } + } + + if (!resource.password) { + logger.error(`[${requestId}] No password set for password-protected ${resource.id}`) + return { authorized: false, error: 'Authentication configuration error' } + } + + const ip = request.clientIp + if (ip) { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `${cookiePrefix}-password:ip:${resource.id}:${ip}`, + PASSWORD_IP_RATE_LIMIT, + { failClosed: true } + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, { + resourceId: resource.id, + cookiePrefix, + ip, + }) + return passwordRateLimitResult( + ipRateLimit.retryAfterMs, + PASSWORD_IP_RATE_LIMIT.refillIntervalMs + ) + } + } + + const resourceRateLimit = await rateLimiter.checkRateLimitDirect( + `${cookiePrefix}-password:resource:${resource.id}`, + PASSWORD_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceRateLimit.allowed) { + logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, { + resourceId: resource.id, + cookiePrefix, + }) + return passwordRateLimitResult( + resourceRateLimit.retryAfterMs, + PASSWORD_RESOURCE_RATE_LIMIT.refillIntervalMs + ) + } + + const { decrypted } = await decryptSecret(resource.password) + if (!safeCompare(password, decrypted)) { + return { authorized: false, error: 'Invalid password' } + } + + return { authorized: true } + } catch (error) { + logger.error(`[${requestId}] Error validating password:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + if (authType === 'email') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_email' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Email is required' } + } + + const { email, input } = parsedBody + + if (input && !email) { + return { authorized: false, error: 'auth_required_email' } + } + + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + if (isEmailAllowed(email, resource.allowedEmails)) { + return { authorized: false, error: 'otp_required' } + } + + return { authorized: false, error: 'Email not authorized' } + } catch (error) { + logger.error(`[${requestId}] Error validating email:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + if (authType === 'sso') { + try { + if (request.method !== 'GET' && !parsedBody) { + return { authorized: false, error: 'SSO authentication is required' } + } + + if (!request.sessionPresent) { + return { authorized: false, error: 'auth_required_sso' } + } + + const userEmail = request.sessionEmail + if (!userEmail) { + return { authorized: false, error: 'SSO session does not contain email' } + } + + if (isEmailAllowed(userEmail, resource.allowedEmails)) { + return { authorized: true, authenticatedEmail: normalizeEmail(userEmail) } + } + + return { authorized: false, error: 'Your email is not authorized to access this resource' } + } catch (error) { + logger.error(`[${requestId}] Error validating SSO:`, error) + return { authorized: false, error: 'SSO authentication error' } + } + } + + return { authorized: false, error: 'Unsupported authentication type' } +} diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 55c75aaf841..8ea840966cf 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -259,22 +259,29 @@ export function deploymentAuthCookieName(cookiePrefix: DeploymentAuthKind, id: s return `${cookiePrefix}_auth_${id}` } -/** Sets a signed, resource-bound authentication cookie for a deployment. */ -export async function setDeploymentAuthCookie({ - response, +/** Builds the existing deployment cookie without coupling domain authorization to an HTTP response. */ +export async function createDeploymentAuthCookie({ cookiePrefix, resource, verifiedEmail, -}: SetDeploymentAuthCookieParams): Promise { - response.cookies.set({ +}: Omit) { + return { name: deploymentAuthCookieName(cookiePrefix, resource.id), value: await generateAuthToken(resource, verifiedEmail), - httpOnly: true, + httpOnly: true as const, secure: !isDev, - sameSite: 'lax', + sameSite: 'lax' as const, path: '/', maxAge: DEPLOYMENT_AUTH_TOKEN_TTL_MS / 1000, - }) + } +} + +/** Sets a signed, resource-bound authentication cookie for a deployment. */ +export async function setDeploymentAuthCookie({ + response, + ...input +}: SetDeploymentAuthCookieParams): Promise { + response.cookies.set(await createDeploymentAuthCookie(input)) } /** diff --git a/apps/sim/lib/core/security/otp.ts b/apps/sim/lib/core/security/otp.ts index e8ac4636c9f..47435216274 100644 --- a/apps/sim/lib/core/security/otp.ts +++ b/apps/sim/lib/core/security/otp.ts @@ -152,7 +152,7 @@ export async function getOTP( const ATOMIC_INCREMENT_SCRIPT = ` local val = redis.call('GET', KEYS[1]) if not val then return nil end -local colon = val:find(':([^:]*$)') +local colon = val:find(':([^:]*)$') local otp, attempts if colon then otp = val:sub(1, colon - 1) diff --git a/apps/sim/lib/desktop/executor/bound-turn.integration.ts b/apps/sim/lib/desktop/executor/bound-turn.integration.ts index fe8db90eb05..a25f6248bc7 100644 --- a/apps/sim/lib/desktop/executor/bound-turn.integration.ts +++ b/apps/sim/lib/desktop/executor/bound-turn.integration.ts @@ -511,7 +511,7 @@ describe.runIf(Boolean(redisUrl))("a turn bound to a desktop's background execut await lapse(toolCallId, 'lease') /** A Sim tool waiting on the same run sweeps its expired executions; this one is not its. */ - await revokeExpiredSimToolExecutions({ runId: run.runId, userId }) + await revokeExpiredSimToolExecutions({ runId: run.runId, userId, toolCallId: 'waiting-call' }) expect((await storedCall(toolCallId)).status).toBe('running') await answer const late = await desktop.complete(toolCallId, executionToken, { output: 'built' }) diff --git a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts index 83cac379378..482c852955f 100644 --- a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts +++ b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts @@ -32,6 +32,19 @@ export const workspaceFileBroker: SandboxBroker MAX_SANDBOX_IMAGE_DATA_URI_CHARS || + JSON.stringify({ dataUri: prefix }).length + encodedLength > + MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS + ) { + throw new Error('Prepared file exceeds the document broker byte limit') + } + return { dataUri: `${prefix}${file.content.toString('base64')}` } + } if (!ctx.workspaceId) { throw new Error('workspaceFile broker requires a workspaceId') } diff --git a/apps/sim/lib/execution/sandbox/run-task.ts b/apps/sim/lib/execution/sandbox/run-task.ts index 78bdac1bf3d..055c89c3443 100644 --- a/apps/sim/lib/execution/sandbox/run-task.ts +++ b/apps/sim/lib/execution/sandbox/run-task.ts @@ -13,6 +13,8 @@ import { getSandboxTask, type SandboxTaskId } from '@/sandboxes/registry' const logger = createLogger('SandboxRunTask') export interface RunSandboxTaskOptions { + /** Private document compiles expose only their already-authorized, fixed input manifest. */ + resolvePreparedFile?: SandboxBrokerContext['resolvePreparedFile'] /** * Owner key used by the isolated-vm pool for fairness + distributed leases. * Typically `user:` or `workspace:`. @@ -61,6 +63,7 @@ export async function runSandboxTask( const brokerContext: SandboxBrokerContext = { workspaceId: input.workspaceId, requestId, + resolvePreparedFile: options.resolvePreparedFile, onWorkspaceFileAccess: options.onWorkspaceFileAccess, filePrincipal: options.filePrincipal, } diff --git a/apps/sim/lib/execution/sandbox/types.ts b/apps/sim/lib/execution/sandbox/types.ts index eaa3066da29..776e0a659dd 100644 --- a/apps/sim/lib/execution/sandbox/types.ts +++ b/apps/sim/lib/execution/sandbox/types.ts @@ -24,8 +24,9 @@ export interface SandboxBroker { } export interface SandboxBrokerContext { - workspaceId: string + workspaceId?: string requestId: string + resolvePreparedFile?: (fileId: string) => { content: Buffer; contentType: string } /** Host-side identity used to authorize and admit each actual broker read. Never sent to the isolate. */ filePrincipal?: Principal onWorkspaceFileAccess?: (identity: { @@ -37,7 +38,7 @@ export interface SandboxBrokerContext { } export interface SandboxTaskInput { - workspaceId: string + workspaceId?: string code: string /** Module filename for `code` when the task sets `userModules`; appears in stack traces. */ codeFilename?: string diff --git a/apps/sim/lib/file-parsers/csv-preview-slice.test.ts b/apps/sim/lib/file-parsers/csv-preview-slice.test.ts index 000fcacfa49..e480f6b6da3 100644 --- a/apps/sim/lib/file-parsers/csv-preview-slice.test.ts +++ b/apps/sim/lib/file-parsers/csv-preview-slice.test.ts @@ -81,3 +81,74 @@ describe('getCsvPreviewSlice', () => { expect(destroySpy).toHaveBeenCalled() }) }) + +it('ends at an oversized streamed record with the rows before it, without consuming the file', async () => { + let chunksRead = 0 + const source = Readable.from( + (async function* () { + yield Buffer.from('header\nfirst\n"') + for (let i = 0; i < 1000; i++) { + chunksRead++ + yield Buffer.alloc(16 * 1024, 'x') + } + })() + ) + mockDownloadFileStream.mockResolvedValue(source) + await expect(getCsvPreviewSlice(args)).resolves.toEqual({ + headers: ['header'], + rows: [['first']], + truncated: true, + }) + expect(chunksRead).toBeLessThan(30) + expect(source.destroyed).toBe(true) +}) + +it('bounds bytes consumed even when blank records produce no preview rows', async () => { + let chunksRead = 0 + const source = Readable.from( + (async function* () { + yield Buffer.from('header\n') + for (let i = 0; i < 1000; i++) { + chunksRead++ + yield Buffer.alloc(64 * 1024, '\n') + } + })() + ) + mockDownloadFileStream.mockResolvedValue(source) + await expect(getCsvPreviewSlice(args)).resolves.toEqual({ + headers: ['header'], + rows: [], + truncated: true, + }) + expect(chunksRead).toBeLessThan(140) + expect(source.destroyed).toBe(true) +}) + +it('returns the leading rows of a file whose rows exhaust the byte budget before the row cap', async () => { + const wide = 'x'.repeat(10 * 1024) + const source = Readable.from( + (async function* () { + yield Buffer.from('id,payload\n') + for (let i = 0; i < CSV_PREVIEW_MAX_ROWS + 100; i++) yield Buffer.from(`${i},${wide}\n`) + })() + ) + mockDownloadFileStream.mockResolvedValue(source) + const slice = await getCsvPreviewSlice(args) + expect(slice.truncated).toBe(true) + expect(slice.headers).toEqual(['id', 'payload']) + expect(slice.rows.length).toBeGreaterThan(0) + expect(slice.rows.length).toBeLessThan(CSV_PREVIEW_MAX_ROWS) + expect(slice.rows.map(([id]) => id)).toEqual(slice.rows.map((_, index) => String(index))) +}) + +it.each([ + ['a quote followed by other characters', 'a,b\n1,2\n"x"y,3\n'], + ['a quote left open at the end of the file', 'a,b\n1,2\n"open,3'], +])('skips a malformed record with %s instead of failing the preview', async (_, text) => { + mockDownloadFileStream.mockResolvedValue(streamOf(text)) + await expect(getCsvPreviewSlice(args)).resolves.toEqual({ + headers: ['a', 'b'], + rows: [['1', '2']], + truncated: false, + }) +}) diff --git a/apps/sim/lib/file-parsers/csv-preview-slice.ts b/apps/sim/lib/file-parsers/csv-preview-slice.ts index 2f4c9369ed9..74c506b44f9 100644 --- a/apps/sim/lib/file-parsers/csv-preview-slice.ts +++ b/apps/sim/lib/file-parsers/csv-preview-slice.ts @@ -1,12 +1,15 @@ import { Readable } from 'node:stream' import { truncate } from '@sim/utils/string' -import { parse as parseCsvStream } from 'csv-parse' +import { CsvError, parse as parseCsvStream } from 'csv-parse' import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { StorageContext } from '@/lib/uploads/config' import { downloadFileStream } from '@/lib/uploads/core/storage-service' /** Cap a single cell so one pathological field can't bloat the preview payload. */ const MAX_CELL_LENGTH = 2_000 +const MAX_RECORD_SIZE = 256 * 1024 +const MAX_PREVIEW_BYTES = 8 * 1024 * 1024 /** Read at most this many bytes while sniffing the first line for the delimiter. */ const DELIMITER_SNIFF_MAX_BYTES = 256 * 1024 @@ -20,7 +23,10 @@ interface CsvPreviewSliceArgs { export interface CsvPreviewSlice { headers: string[] rows: string[][] - /** True when the file has more than {@link CSV_PREVIEW_MAX_ROWS} data rows. */ + /** + * True when the preview stops before the end of the file: past {@link CSV_PREVIEW_MAX_ROWS} data + * rows, or at the byte or record-size budget. + */ truncated: boolean } @@ -41,10 +47,20 @@ function cell(value: unknown): string { return truncate(String(value ?? ''), MAX_CELL_LENGTH) } +/** Running out of the byte or record-size budget ends the preview early instead of failing it. */ +function exhaustsPreviewBudget(error: unknown): boolean { + return ( + isPayloadSizeLimitError(error) || + (error instanceof CsvError && error.code === 'CSV_MAX_RECORD_SIZE') + ) +} + /** * Streams the first {@link CSV_PREVIEW_MAX_ROWS} rows of a CSV/TSV from storage without * ever buffering the whole file. The source stream is destroyed as soon as enough rows are - * read (one past the cap, to detect truncation), so a multi-GB file costs O(rows) of memory. + * read (one past the cap, to detect truncation), with record and total-byte limits bounding parser + * memory and work. Reaching either limit returns the rows read so far as a truncated preview, and a + * malformed record is skipped rather than failing the preview. */ export async function getCsvPreviewSlice({ key, @@ -60,6 +76,19 @@ export async function getCsvPreviewSlice({ signal?.addEventListener('abort', onAbort, { once: true }) const reader = source[Symbol.asyncIterator]() + let bytesRead = 0 + let piped: Readable | undefined + let parser: ReturnType | undefined + let headers: string[] = [] + const rows: string[][] = [] + async function nextChunk() { + const next = await reader.next() + if (!next.done) { + bytesRead += Buffer.byteLength(next.value) + assertKnownSizeWithinLimit(bytesRead, MAX_PREVIEW_BYTES, 'CSV preview') + } + return next + } try { // Pull chunks until the first newline so the delimiter can be sniffed before parsing. @@ -71,12 +100,14 @@ export async function getCsvPreviewSlice({ let firstLine = '' let sniffedBytes = 0 while (true) { - const { value, done } = await reader.next() + const { value, done } = await nextChunk() if (done) break const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value) sniffed.push(chunk) sniffedBytes += chunk.length - const text = chunk.toString('utf-8') + const text = chunk + .subarray(0, Math.max(0, DELIMITER_SNIFF_MAX_BYTES - sniffedBytes + chunk.length)) + .toString('utf-8') const nl = text.indexOf('\n') if (nl !== -1) { firstLine += text.slice(0, nl) @@ -91,56 +122,69 @@ export async function getCsvPreviewSlice({ } const delimiter = detectDelimiter(firstLine) - const parser = parseCsvStream({ + let headersSet = false + let truncated = false + let stopParsing = () => {} + const csvParser = parseCsvStream({ columns: false, skip_empty_lines: true, trim: true, relax_column_count: true, relax_quotes: true, skip_records_with_error: true, + on_skip(error) { + if (error?.code === 'CSV_MAX_RECORD_SIZE') throw error + }, + max_record_size: MAX_RECORD_SIZE, cast: false, bom: true, delimiter, + /** + * Rows are kept as the parser produces them rather than read from its output buffer, so a + * preview cut short by a budget still holds every complete row parsed before the cut. + */ + on_record(record: string[]) { + if (truncated) return null + if (!headersSet) { + headers = record.map(cell) + headersSet = true + } else if (rows.length < CSV_PREVIEW_MAX_ROWS) { + rows.push(record.map(cell)) + } else { + truncated = true + stopParsing() + } + return null + }, + }) + parser = csvParser + const parsed = new Promise((resolve, reject) => { + stopParsing = resolve + csvParser.on('error', reject).on('end', resolve).resume() }) // Re-feed the sniffed prefix, then drain the rest of the source into the parser. async function* rejoin() { for (const chunk of sniffed) yield chunk while (true) { - const { value, done } = await reader.next() + const { value, done } = await nextChunk() if (done) return yield value } } - const piped = Readable.from(rejoin()) - piped.on('error', (err) => parser.destroy(err)) - piped.pipe(parser) - - let headers: string[] = [] - let headersSet = false - const rows: string[][] = [] - let truncated = false - - for await (const record of parser as AsyncIterable) { - if (!headersSet) { - headers = record.map(cell) - headersSet = true - continue - } - if (rows.length >= CSV_PREVIEW_MAX_ROWS) { - truncated = true - break - } - rows.push(record.map(cell)) - } + piped = Readable.from(rejoin()) + piped.on('error', (err) => csvParser.destroy(err)) + piped.pipe(csvParser) - piped.destroy() - parser.destroy() + await parsed return { headers, rows, truncated } } catch (error) { if (signal?.aborted) signal.throwIfAborted() + if (exhaustsPreviewBudget(error)) return { headers, rows, truncated: true } throw error } finally { + piped?.destroy() + parser?.destroy() signal?.removeEventListener('abort', onAbort) source.destroy() } diff --git a/apps/sim/lib/mothership/__integration__/content-delivery.integration.ts b/apps/sim/lib/mothership/__integration__/content-delivery.integration.ts index ed4ceb43c83..70c74fca5a3 100644 --- a/apps/sim/lib/mothership/__integration__/content-delivery.integration.ts +++ b/apps/sim/lib/mothership/__integration__/content-delivery.integration.ts @@ -132,6 +132,7 @@ import { } from '@/lib/mothership/__integration__/content-delivery-fixture' import { observePrivateFile } from '@/lib/mothership/agent-cli/engines/observe-private-file' import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport' +import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance' import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' import { createRegistryContentGate } from '@/lib/mothership/content-delivery/registry-gate' import { proxySandboxResourceRequest } from '@/lib/mothership/tools/sandbox-resource-transport' @@ -251,6 +252,12 @@ describe('content reaching Chat is admitted per item', () => { chatId: generateId(), }, registry, + files: createWorkbenchFileProvenance({ + userId: fixture.ids.aliceId, + workspaceId: fixture.ids.workspaceId, + sessionKey: generateId(), + resolvedSecretTraceRegistry: registry, + }), resources: [], observeReads: true, }) diff --git a/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts b/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts index 6fee8cbd8ed..a0638b0f092 100644 --- a/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts @@ -59,10 +59,12 @@ vi.mock('@/lib/mothership/agent-cli/scoped-transport', () => ({ import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport' import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport' +import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance' import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' import { createRegistryContentGate } from '@/lib/mothership/content-delivery' import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileArtifact } from '@/lib/workspace-files/application/read-workspace-file-artifact' @@ -118,6 +120,31 @@ function registry() { return new ResolvedSecretTraceRegistry([], { userId: 'reader', workspaceId: 'workspace' }) } +function classify( + status: 'exact' | 'unknown', + fileRevision = revision, + entries: Extract['entries'] = [], + includePublicationRows = true +) { + resetDbChainMock() + const sidecar = { + id: file.id, + key: file.key, + context: file.storageContext, + fileContentUpdatedAt: fileRevision, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: fileRevision, + status, + entries, + } + queueTableRows(workspaceFiles, [sidecar]) + if (!includePublicationRows) return + queueTableRows(workspaceFiles, [ + { ...file, context: file.storageContext, secretProvenanceVersion: 1 }, + ]) + queueTableRows(workspaceFiles, [sidecar]) +} + type SidecarStatus = 'exact' | 'unknown' function sidecarRow(status: SidecarStatus, fileRevision = revision, entries: unknown[] = []) { @@ -131,7 +158,7 @@ function sidecarRow(status: SidecarStatus, fileRevision = revision, entries: unk } /** Queues one sidecar per status, in the order the reads consume them. */ -function classify(...statuses: [SidecarStatus, ...SidecarStatus[]]) { +function classifySequence(...statuses: [SidecarStatus, ...SidecarStatus[]]) { resetDbChainMock() for (const status of statuses) queueTableRows(workspaceFiles, [sidecarRow(status)]) } @@ -178,11 +205,19 @@ function runCli(invocation: CliInvocation, trace = registry()) { const invocation = { userId: 'reader', workspaceId: 'workspace', chatId: 'chat' } /** Chat's CLI transport as every invocation composes it: the registry gate around the routes. */ -function readTransport(trace = registry()): typeof fetch { +function readTransport(trace = registry(), tracked = true): typeof fetch { const transport = createAgentCliTransport({ endpoint: 'https://sim.test', invocation, registry: trace, + files: tracked + ? createWorkbenchFileProvenance({ + userId: 'reader', + workspaceId: 'workspace', + sessionKey: 'chat', + resolvedSecretTraceRegistry: trace, + }) + : undefined, resources: [], observeReads: true, }) @@ -220,15 +255,7 @@ describe('file provenance at the actual CLI and model-result boundary', () => { mocks.stream.mockImplementation(async () => Readable.from(Buffer.from(content))) mocks.buffer.mockResolvedValue(Buffer.from(content)) mocks.decrypt.mockResolvedValue({ decrypted: content }) - queueTableRows(workspaceFiles, [ - { - fileContentUpdatedAt: revision, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: revision, - status: 'unknown', - entries: [], - }, - ]) + classify('unknown') }) it('uses current Copilot file authority with personal keys disabled and rejects a foreign ID-only owner', async () => { @@ -411,18 +438,19 @@ describe('file provenance at the actual CLI and model-result boundary', () => { ).not.toContain(content) }) + it('refuses opaque protected downloads without workbench provenance tracking', async () => { + classify('exact', revision, [ + { name: 'FILE_SECRET', encryptedValue: 'fixture-ciphertext', sourceUserId: 'reader' }, + ]) + const response = await readTransport(registry(), false)(fileRequest()) + expect(response.status).toBe(403) + expect(await response.text()).not.toContain(content) + expect(mocks.stream).not.toHaveBeenCalled() + }) + it('imports encrypted file provenance into a fresh registry before model projection', async () => { - resetDbChainMock() - queueTableRows(workspaceFiles, [ - { - fileContentUpdatedAt: revision, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: revision, - status: 'exact', - entries: [ - { name: 'FILE_SECRET', encryptedValue: 'fixture-ciphertext', sourceUserId: 'reader' }, - ], - }, + classify('exact', revision, [ + { name: 'FILE_SECRET', encryptedValue: 'fixture-ciphertext', sourceUserId: 'reader' }, ]) const trace = registry() const response = await readTransport(trace)(fileRequest()) @@ -481,7 +509,7 @@ describe('file provenance at the actual CLI and model-result boundary', () => { nextCursor: null, }) }) - classify('unknown') + classify('unknown', revision, [], false) const grep = async () => { const trace = registry() const result = await runCli(grepFiles(content), trace) @@ -523,7 +551,7 @@ describe('file provenance at the actual CLI and model-result boundary', () => { nextCursor: null, }) ) - classify('exact', 'unknown') + classifySequence('exact', 'unknown') const refusals = getMockLogger('DurableSecretProvenancePersistence').error refusals.mockClear() const trace = registry() diff --git a/apps/sim/lib/mothership/agent-cli/file-read-transport.ts b/apps/sim/lib/mothership/agent-cli/file-read-transport.ts index fcc56801d63..3a62be5e96a 100644 --- a/apps/sim/lib/mothership/agent-cli/file-read-transport.ts +++ b/apps/sim/lib/mothership/agent-cli/file-read-transport.ts @@ -38,6 +38,7 @@ export function createFileReadTransport(context: { transport: typeof fetch invocation: CopilotChatDelegationContext & { workspaceId: string } registry?: ResolvedSecretTraceRegistry + tracksWorkbenchDownloads?: boolean }): typeof fetch { const base = new URL(context.endpoint) const basePath = base.pathname.replace(/\/$/, '') @@ -105,6 +106,7 @@ export function createFileReadTransport(context: { workspaceId: parsed.data.query.workspaceId, actorUserId: context.invocation.userId, unknown: 'taint', + destination: context.tracksWorkbenchDownloads ? 'workbench' : 'model', }), () => downloadWorkspaceFileStream.execute({ diff --git a/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts b/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts index 885a539382c..98a664531b3 100644 --- a/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts @@ -190,15 +190,29 @@ function queueRead(file: typeof SOURCE, provenance: WorkspaceFileSecretProvenanc }, ]) queueTableRows(workspaceFiles, [file]) + const sidecar = { + id: file.id, + key: file.key, + context: file.context, + fileContentUpdatedAt: REVISION, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: REVISION, + ...provenance, + entries: provenance.status === 'exact' ? provenance.entries : [], + } + queueTableRows(workspaceFiles, [sidecar]) queueTableRows(workspaceFiles, [ { - fileContentUpdatedAt: REVISION, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: REVISION, - ...provenance, - entries: provenance.status === 'exact' ? provenance.entries : [], + workspaceId: WORKSPACE, + fileId: file.id, + ownership: file, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner', }, ]) + queueTableRows(workspaceFiles, [file]) + queueTableRows(workspaceFiles, [sidecar]) } function execute(argv: string[], trace: ResolvedSecretTraceRegistry, sink?: string) { diff --git a/apps/sim/lib/mothership/agent-cli/transport.ts b/apps/sim/lib/mothership/agent-cli/transport.ts index f400833f499..c56690b4d61 100644 --- a/apps/sim/lib/mothership/agent-cli/transport.ts +++ b/apps/sim/lib/mothership/agent-cli/transport.ts @@ -40,6 +40,7 @@ export function createAgentCliTransport(context: { transport: createTracedCliTransport(endpoint, createScopedCliTransport(endpoint, invocation)), invocation, registry, + tracksWorkbenchDownloads: Boolean(files), }) const reads: typeof fetch = async (input, init) => { const { response, admitted } = await dispatchUnderContentGate(gate, () => routes(input, init)) diff --git a/apps/sim/lib/mothership/async-runs/browser-download-claim.integration.ts b/apps/sim/lib/mothership/async-runs/browser-download-claim.integration.ts index ef030704544..236672f338d 100644 --- a/apps/sim/lib/mothership/async-runs/browser-download-claim.integration.ts +++ b/apps/sim/lib/mothership/async-runs/browser-download-claim.integration.ts @@ -52,6 +52,7 @@ describe('browser download admission with PostgreSQL', () => { claimed_at timestamp, claimed_by text, execution_started_at timestamp, execution_settled_at timestamp, execution_owner_token text, execution_lease_expires_at timestamptz, execution_revoked_at timestamptz, + execution_attempts integer NOT NULL DEFAULT 0, client_workflow_execution_id text, sandbox_processes jsonb NOT NULL DEFAULT '{}', completed_at timestamp, created_at timestamp NOT NULL DEFAULT now(), updated_at timestamp NOT NULL DEFAULT now() diff --git a/apps/sim/lib/mothership/async-runs/execution-lease.ts b/apps/sim/lib/mothership/async-runs/execution-lease.ts index 2a2bbc25b60..036014c33a7 100644 --- a/apps/sim/lib/mothership/async-runs/execution-lease.ts +++ b/apps/sim/lib/mothership/async-runs/execution-lease.ts @@ -9,9 +9,18 @@ export interface SimToolExecutionOwner { ownerToken: string } +/** + * Sim executions one call may start: the first, and a re-run each time a takeover finds the + * previous owner died before recording a result. A tool that kills its executor every time + * ends with {@link EXHAUSTED_SIM_TOOL_MESSAGE} instead of re-running forever. + */ +export const SIM_TOOL_EXECUTION_MAX_ATTEMPTS = 3 + export const INTERRUPTED_SIM_TOOL_MESSAGE = 'Tool execution lost its owner before a result was recorded. Its outcome is unknown; inspect current state before retrying a mutation.' +export const EXHAUSTED_SIM_TOOL_MESSAGE = `Tool execution was interrupted ${SIM_TOOL_EXECUTION_MAX_ATTEMPTS} times because the Sim server running it stopped, so it was not run again. Its outcome is unknown; inspect current state before retrying a mutation.` + export class SimToolExecutionLeaseLostError extends Error { constructor() { super(INTERRUPTED_SIM_TOOL_MESSAGE) diff --git a/apps/sim/lib/mothership/async-runs/orphaned-runs.ts b/apps/sim/lib/mothership/async-runs/orphaned-runs.ts index cbee9b04d24..3bd93cad2b2 100644 --- a/apps/sim/lib/mothership/async-runs/orphaned-runs.ts +++ b/apps/sim/lib/mothership/async-runs/orphaned-runs.ts @@ -73,6 +73,8 @@ export const ORPHANED_RUN_GRACE_MS = 60 * 60 * 1000 export const LEGACY_RUN_GRACE_MS = 24 * 60 * 60 * 1000 export const ORPHANED_RUN_ERROR = 'This response was interrupted before it finished.' +export const REVOKED_RUN_ERROR = + 'This response was stopped because you no longer have access to continue it.' export const LEGACY_RUN_ERROR = 'Run was never finalized (pre-lease run).' const SWEEP_BATCH_SIZE = 500 @@ -124,7 +126,7 @@ const stopRequested = sql`(EXISTS (SELECT 1 FROM ${copilotRequestStops} s WHERE s.user_id = ${copilotRuns.userId} AND s.organization_id = ${copilotRuns.organizationId} AND s.stream_id = ${copilotRuns.streamId}))` -interface UnownedRun { +export interface UnownedRun { id: string chatId: string streamId: string @@ -144,9 +146,15 @@ const unownedRunColumns = { controllerToken, } +const SETTLED_RUN_ERRORS = { + orphaned: ORPHANED_RUN_ERROR, + revoked: REVOKED_RUN_ERROR, + legacy: LEGACY_RUN_ERROR, +} as const + /** A run ends cancelled only if its user pressed Stop, whoever settles it. */ -function terminalValues(reason: 'orphaned' | 'legacy') { - const error = reason === 'orphaned' ? ORPHANED_RUN_ERROR : LEGACY_RUN_ERROR +function terminalValues(reason: keyof typeof SETTLED_RUN_ERRORS) { + const error = SETTLED_RUN_ERRORS[reason] return { status: sql`(CASE WHEN ${stopRequested} THEN 'cancelled' ELSE 'error' END)::copilot_run_status`, error: sql`CASE WHEN ${stopRequested} THEN NULL ELSE ${error}::text END`, @@ -171,7 +179,8 @@ function terminalValues(reason: 'orphaned' | 'legacy') { async function settleRuns( tx: DbTransaction, runs: UnownedRun[], - guard: SQL | undefined + guard: SQL | undefined, + reason: Exclude = 'orphaned' ): Promise<{ settled: UnownedRun[]; released: UnownedRun[] }> { if (runs.length === 0) return { settled: [], released: [] } const chatIds = [...new Set(runs.map((run) => run.chatId))] @@ -235,7 +244,7 @@ async function settleRuns( for (const run of runs) { if (run.controllerToken === null) continue await apply([run], eq(controllerToken, run.controllerToken), { - ...terminalValues('orphaned'), + ...terminalValues(reason), completedAt: sql`now()`, updatedAt: sql`now()`, }) @@ -337,9 +346,9 @@ async function releaseChatLocks(fences: ChatLockFence[]): Promise { ) } -async function readSweepCursor(): Promise { +async function readSweepCursor(key = SWEEP_CURSOR_KEY): Promise { try { - return (await getRedisClient()?.get(SWEEP_CURSOR_KEY)) ?? undefined + return (await getRedisClient()?.get(key)) ?? undefined } catch (error) { logger.warn('Orphaned-run sweep cursor is unreadable; starting from the first run', { error: getErrorMessage(error), @@ -348,12 +357,12 @@ async function readSweepCursor(): Promise { } } -async function writeSweepCursor(cursor: string | undefined): Promise { +async function writeSweepCursor(cursor: string | undefined, key = SWEEP_CURSOR_KEY): Promise { try { const redis = getRedisClient() if (!redis) return - if (cursor) await redis.set(SWEEP_CURSOR_KEY, cursor, 'EX', SWEEP_CURSOR_TTL_SECONDS) - else await redis.del(SWEEP_CURSOR_KEY) + if (cursor) await redis.set(key, cursor, 'EX', SWEEP_CURSOR_TTL_SECONDS) + else await redis.del(key) } catch (error) { logger.warn('Orphaned-run sweep cursor could not be saved', { error: getErrorMessage(error) }) } @@ -453,3 +462,96 @@ export async function settleStoppedRunWithoutController(runId: string): Promise< announceReleased(released) return settled.length > 0 } + +/** + * Settles a run its controller abandoned as an error when its user may no longer continue + * it. The run's chat lock is fenced as a takeover's would be, so a reconnect cannot claim + * the run while it settles; a run whose lock is held, or whose controller changed, is left. + */ +export async function settleRunWithoutAccess(run: UnownedRun): Promise { + if (!run.controllerToken) return false + const fences = await fenceChatLocks([run]) + if (fences.length === 0) return false + try { + const { settled, released } = await db.transaction((tx) => + settleRuns(tx, [run], undefined, 'revoked') + ) + announceReleased(released) + return settled.length > 0 + } finally { + await releaseChatLocks(fences) + } +} + +/** + * A run its controller is still driving writes its status at least once per leg, and a dead + * controller's chat lock lapses within this long, so a run written to more recently is not + * worth checking yet. + */ +const ABANDONED_RUN_IDLE_MS = 60_000 +/** Where the last takeover scan stopped, so live runs it skipped never hide the runs after them. */ +export const ABANDONED_RUN_CURSOR_KEY = 'copilot:abandoned-runs:scan-cursor' + +/** + * Runs an interactive controller abandoned that a takeover can still resume: unfinished and + * not stopped, saved with their recovery intent, idle for a minute but within the recovery + * window, and with no controller holding their chat lock. Each scan examines at most + * `maxExamined` candidates, resuming after the last one the previous scan examined and wrapping + * to the first, so runs whose controller is alive cost one examination per pass and never + * crowd out an abandoned run. Empty while the locks cannot be read, since then no run is + * provably abandoned. + */ +export async function findAbandonedRecoverableRuns( + limit: number, + { pageSize = SWEEP_BATCH_SIZE, maxExamined = SWEEP_BATCH_SIZE } = {} +): Promise { + const abandoned: UnownedRun[] = [] + const start = await readSweepCursor(ABANDONED_RUN_CURSOR_KEY) + let cursor = start + let wrapped = start === undefined + let examined = 0 + while (abandoned.length < limit && examined < maxExamined) { + const size = Math.min(pageSize, maxExamined - examined) + const page: UnownedRun[] = await db + .select(unownedRunColumns) + .from(copilotRuns) + .where( + and( + inArray(copilotRuns.status, UNFINISHED_RUN_STATUSES), + isNotNull(controllerToken), + sql`${copilotRuns.requestContext}->'recovery'->>'kind' = 'interactive_stream'`, + idleFor(ABANDONED_RUN_IDLE_MS), + not(idleFor(ORPHANED_RUN_GRACE_MS)), + not(stopRequested), + cursor ? gt(copilotRuns.id, cursor) : undefined, + wrapped && start ? lte(copilotRuns.id, start) : undefined + ) + ) + .orderBy(asc(copilotRuns.id)) + .limit(size) + let locked: Set + try { + locked = page.length > 0 ? await findStreamsHoldingChatLock(page) : new Set() + } catch (error) { + logger.warn('Chat stream ownership is unreadable; no run is taken over this round', { + error: getErrorMessage(error), + }) + return [] + } + for (const run of page) { + examined++ + cursor = run.id + if (locked.has(run.streamId)) continue + abandoned.push(run) + if (abandoned.length === limit) break + } + if (abandoned.length < limit && page.length < size) { + /** The end of the table: wrap once to cover the runs before the starting point. */ + cursor = undefined + if (wrapped) break + wrapped = true + } + } + await writeSweepCursor(cursor, ABANDONED_RUN_CURSOR_KEY) + return abandoned +} diff --git a/apps/sim/lib/mothership/async-runs/repository.test.ts b/apps/sim/lib/mothership/async-runs/repository.test.ts index 9f8577afd12..7ae9662d040 100644 --- a/apps/sim/lib/mothership/async-runs/repository.test.ts +++ b/apps/sim/lib/mothership/async-runs/repository.test.ts @@ -5,7 +5,13 @@ import { copilotRequestStops, copilotRuns, } from '@sim/db/schema' -import { dbChainMockFns, hasMockCondition, queueTableRows, resetDbChainMock } from '@sim/testing' +import { + dbChainMockFns, + flattenMockConditions, + hasMockCondition, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' import { areStreamToolExecutionsSettled, @@ -185,6 +191,21 @@ describe('run admission and early Stop', () => { }) }) +/** Finds a condition anywhere in a mocked expression, including inside `sql` and `sql.join` fragments. */ +function someNestedCondition( + node: unknown, + predicate: (condition: Record) => boolean +): boolean { + if (!node || typeof node !== 'object') return false + const record = node as Record + if (predicate(record)) return true + return ['conditions', 'values', 'fragments'].some( + (key) => + Array.isArray(record[key]) && + (record[key] as unknown[]).some((child) => someNestedCondition(child, predicate)) + ) +} + describe('durable Sim tool ownership', () => { beforeEach(() => { resetDbChainMock() @@ -240,14 +261,26 @@ describe('durable Sim tool ownership', () => { (condition) => condition.type === 'eq' && condition.right === input.userId ) ).toBe(true) + /** A first execution, or a re-run of one whose owner died before it recorded a result. */ + const claimable = flattenMockConditions(dbChainMockFns.where.mock.calls[1]?.[0]).find( + (condition) => condition.type === 'or' + ) + const [firstExecution, interruptedExecution] = (claimable?.conditions ?? []) as unknown[] expect( hasMockCondition( - dbChainMockFns.where.mock.calls[1]?.[0], + firstExecution, (condition) => condition.type === 'isNull' && condition.column === copilotAsyncToolCalls.executionStartedAt ) ).toBe(true) + expect( + someNestedCondition( + interruptedExecution, + (condition) => + condition.type === 'lt' && condition.left === copilotAsyncToolCalls.executionAttempts + ) + ).toBe(true) expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ executionStartedAt: expect.any(Date), status: 'running' }) ) diff --git a/apps/sim/lib/mothership/async-runs/repository.ts b/apps/sim/lib/mothership/async-runs/repository.ts index 72b230afba0..42a9a122265 100644 --- a/apps/sim/lib/mothership/async-runs/repository.ts +++ b/apps/sim/lib/mothership/async-runs/repository.ts @@ -17,10 +17,14 @@ import { and, desc, eq, + gte, inArray, isNotNull, isNull, + like, + lt, ne, + not, notInArray, or, type SQL, @@ -32,8 +36,10 @@ import type { SessionProcessIdentity } from '@/lib/execution/remote-sandbox/sess import { AsyncToolCallOwnershipError } from '@/lib/mothership/async-runs/errors' import { executableToolPermission } from '@/lib/mothership/async-runs/executable-tool-permission' import { + EXHAUSTED_SIM_TOOL_MESSAGE, INTERRUPTED_SIM_TOOL_MESSAGE, SIM_TOOL_EXECUTION_LEASE_SECONDS, + SIM_TOOL_EXECUTION_MAX_ATTEMPTS, SimToolExecutionLeaseLostError, type SimToolExecutionOwner, } from '@/lib/mothership/async-runs/execution-lease' @@ -728,7 +734,59 @@ export type ToolExecutionClaim = /** Already claimed or settled. */ | { outcome: 'existing' } -/** Sim claims a call it is about to execute under an execution lease; a terminal result never releases it. */ +/** + * Every condition at once, typed as a condition: drizzle's `and` may return undefined, which a + * negation would render as `NOT (NULL)` and match nothing. + */ +function allOf(...conditions: SQL[]): SQL { + return sql`(${sql.join( + conditions.map((condition) => sql`(${condition})`), + sql` and ` + )})` +} + +/** + * A Sim handler's execution of a call that has no result yet. Only a Sim handler sets + * `executionStartedAt` without a browser workflow execution, so desktop and browser + * executions, which settle their own lapses, never match. + */ +const unfinishedSimExecution = allOf( + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.running), + isNotNull(copilotAsyncToolCalls.executionStartedAt), + isNull(copilotAsyncToolCalls.executionSettledAt), + isNull(copilotAsyncToolCalls.executionRevokedAt), + isNull(copilotAsyncToolCalls.clientWorkflowExecutionId) +) + +/** + * A Sim execution whose owner died before recording a result: its lease lapsed, which a live + * owner's heartbeat never lets happen. A takeover runs it again until it has started + * {@link SIM_TOOL_EXECUTION_MAX_ATTEMPTS} times. + */ +const interruptedSimExecution = allOf( + unfinishedSimExecution, + sql`${copilotAsyncToolCalls.executionLeaseExpiresAt} <= clock_timestamp()`, + lt(copilotAsyncToolCalls.executionAttempts, SIM_TOOL_EXECUTION_MAX_ATTEMPTS) +) + +/** + * An interrupted execution a takeover may still run again: its run still admits tools. Another + * call's wait or workbench access must not revoke it as interrupted before that re-run. + */ +const rerunnableSimExecution = allOf( + interruptedSimExecution, + sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.tool_admission_closed_at IS NULL AND r.status NOT IN (${sql.join( + TERMINAL_RUN_STATUSES.map((status) => sql`${status}`), + sql`, ` + )}))` +) + +/** + * Sim claims a call it is about to execute under an execution lease; a terminal result never + * releases it. The claim takes a call's first execution, or re-runs one whose owner died + * mid-execution: the new owner token fences the dead owner out, since its renewals and its + * result both require the token and an unexpired lease. + */ export async function claimToolExecution( owner: SimToolExecutionOwner ): Promise { @@ -744,16 +802,22 @@ export async function claimToolExecution( executionStartedAt: startedAt, executionOwnerToken: owner.ownerToken, executionLeaseExpiresAt: sql`clock_timestamp() + ${SIM_TOOL_EXECUTION_LEASE_SECONDS} * interval '1 second'`, + executionAttempts: sql`${copilotAsyncToolCalls.executionAttempts} + 1`, updatedAt: startedAt, }) .where( and( thisCall, - isNull(copilotAsyncToolCalls.executionStartedAt), - inArray(copilotAsyncToolCalls.status, [ - ASYNC_TOOL_STATUS.pending, - ASYNC_TOOL_STATUS.running, - ]) + or( + and( + isNull(copilotAsyncToolCalls.executionStartedAt), + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.pending, + ASYNC_TOOL_STATUS.running, + ]) + ), + interruptedSimExecution + ) ) ) .returning({ id: copilotAsyncToolCalls.id }) @@ -930,17 +994,68 @@ export async function getChatViewDesktopLeaseRemainingMs( return row ? Number(row.remainingMs) : null } -/** Revocation ends local execution authority; recorded remote commands remain independently unsettled. */ -export async function revokeExpiredSimToolExecutions(input: { runId: string; userId: string }) { +/** Why a lapsed execution ended without a result: once, or after every re-run it was allowed. */ +const interruptionMessage = sql`CASE WHEN ${copilotAsyncToolCalls.executionAttempts} >= ${SIM_TOOL_EXECUTION_MAX_ATTEMPTS} THEN ${EXHAUSTED_SIM_TOOL_MESSAGE}::text ELSE ${INTERRUPTED_SIM_TOOL_MESSAGE}::text END` + +/** + * Whether one of the run's Sim handlers is still running with its execution lease held. Its + * controller renews that lease from the same process, so a run whose controller is gone holds + * none once the lease lapses; until then, taking the run over would find the call neither + * finished nor re-runnable. + */ +export async function hasLiveSimToolExecution(runId: string): Promise { + const [live] = await db + .select({ id: copilotAsyncToolCalls.id }) + .from(copilotAsyncToolCalls) + .where( + and( + eq(copilotAsyncToolCalls.runId, runId), + unfinishedSimExecution, + sql`${copilotAsyncToolCalls.executionLeaseExpiresAt} > clock_timestamp()` + ) + ) + .limit(1) + return !!live +} + +/** + * Revocation ends local execution authority; recorded remote commands remain independently + * unsettled. The waiting call's own lapsed execution is revoked; another call's is spared while + * a takeover may still run it again. + */ +export async function revokeExpiredSimToolExecutions(input: { + runId: string + userId: string + toolCallId: string +}) { + return db.transaction((tx) => + revokeExpiredExecutions( + tx, + and( + sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.id = ${input.runId}::uuid AND r.user_id = ${input.userId})`, + or(eq(copilotAsyncToolCalls.toolCallId, input.toolCallId), not(rerunnableSimExecution)) + ) + ) + ) +} + +/** + * Fails the run's lapsed executions that already started every time they may, so a takeover + * hands the model a clear failure for each instead of running it again. + */ +export async function revokeExhaustedSimToolExecutions(runId: string) { return db.transaction((tx) => revokeExpiredExecutions( tx, - sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.id = ${input.runId}::uuid AND r.user_id = ${input.userId})` + and( + eq(copilotAsyncToolCalls.runId, runId), + gte(copilotAsyncToolCalls.executionAttempts, SIM_TOOL_EXECUTION_MAX_ATTEMPTS) + ) ) ) } -async function revokeExpiredExecutions(tx: RunAdmissionTransaction, scope: SQL) { +async function revokeExpiredExecutions(tx: RunAdmissionTransaction, scope: SQL | undefined) { const revoked = await tx .update(copilotAsyncToolCalls) .set({ executionRevokedAt: sql`now()` }) @@ -966,8 +1081,8 @@ async function revokeExpiredExecutions(tx: RunAdmissionTransaction, scope: SQL) .update(copilotAsyncToolCalls) .set({ status: ASYNC_TOOL_STATUS.failed, - result: { error: INTERRUPTED_SIM_TOOL_MESSAGE }, - error: INTERRUPTED_SIM_TOOL_MESSAGE, + result: sql`jsonb_build_object('error', ${interruptionMessage})`, + error: interruptionMessage, claimedBy: null, claimedAt: null, completedAt: sql`now()`, @@ -1065,7 +1180,9 @@ export async function recordSimSandboxProcess( throw new Error('Sandbox command admission is closed or unavailable') } const { id, sandboxId, sessionKey } = input.process - const entry = JSON.stringify({ [id]: { sandboxId, sessionKey, settled: false } }) + const entry = JSON.stringify({ + [id]: { sandboxId, sessionKey, settled: false, ownerToken: input.ownerToken }, + }) const [row] = await tx .update(copilotAsyncToolCalls) .set({ @@ -1218,7 +1335,7 @@ export async function prepareWorkbenchAccess( throw new Error('Workbench does not belong to this tool execution') } const [claim] = await tx - .select({ id: copilotAsyncToolCalls.id }) + .select({ processes: copilotAsyncToolCalls.sandboxProcesses }) .from(copilotAsyncToolCalls) .where( and( @@ -1276,7 +1393,11 @@ export async function prepareWorkbenchAccess( ) await revokeExpiredExecutions( tx, - sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.chat_id = ${owner.chatId} AND r.user_id = ${input.userId})` + and( + sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId} AND r.chat_id = ${owner.chatId} AND r.user_id = ${input.userId})`, + /** This run was just verified to admit tools; every older run's admission just closed. */ + not(allOf(eq(copilotAsyncToolCalls.runId, input.runId), interruptedSimExecution)) + ) ) const previous = await tx .select({ @@ -1307,7 +1428,33 @@ export async function prepareWorkbenchAccess( ) ) ) - const processes = previous.flatMap((row) => + /** + * A re-run's earlier attempts may have left commands running on the remote workbench, which + * outlive the Sim process that started them; they are stopped like any earlier call's. + */ + /** A sibling awaiting its re-run has no live handler, but its dead attempt's commands may still run. */ + const rerunnableSiblings = await tx + .select({ + toolCallId: copilotAsyncToolCalls.toolCallId, + processes: copilotAsyncToolCalls.sandboxProcesses, + }) + .from(copilotAsyncToolCalls) + .where( + and( + eq(copilotAsyncToolCalls.runId, input.runId), + ne(copilotAsyncToolCalls.toolCallId, input.toolCallId), + interruptedSimExecution + ) + ) + const earlierAttempts = { + toolCallId: input.toolCallId, + processes: Object.fromEntries( + Object.entries(claim.processes).filter( + ([, process]) => process.ownerToken !== input.ownerToken + ) + ), + } + const processes = [...previous, ...rerunnableSiblings, earlierAttempts].flatMap((row) => Object.entries(row.processes).flatMap(([id, process]) => process.settled ? [] @@ -1480,19 +1627,42 @@ export async function claimWorkflowToolExecution( .where( and( eq(copilotAsyncToolCalls.toolCallId, toolCallId), - isNull(copilotAsyncToolCalls.claimedBy), - isNull(copilotAsyncToolCalls.executionStartedAt), or( - inArray(copilotAsyncToolCalls.status, [ - ASYNC_TOOL_STATUS.running, - ASYNC_TOOL_STATUS.delivered, - ]), and( - eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending), - inArray(copilotAsyncToolCalls.permissionDecision, [ - ...EXECUTABLE_TOOL_PERMISSION_DECISIONS, - ]) - ) + isNull(copilotAsyncToolCalls.claimedBy), + isNull(copilotAsyncToolCalls.executionStartedAt), + or( + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]), + and( + eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending), + inArray(copilotAsyncToolCalls.permissionDecision, [ + ...EXECUTABLE_TOOL_PERMISSION_DECISIONS, + ]) + ) + ) + ), + /** Only the server re-runs a workflow whose Sim executor died; no browser resumes it. */ + executor === 'sim' ? interruptedSimExecution : undefined, + /** + * A server claim that never started, such as one whose claimant died between + * claiming and starting. Nothing here checks the claimant is gone: what keeps + * the call to one execution is `claimToolExecution`, the only start, so if the + * earlier claimant is alive whichever starts second observes the first's result. + */ + executor === 'sim' + ? and( + like(copilotAsyncToolCalls.claimedBy, `${WORKFLOW_EXECUTION_CLAIM_PREFIX}%`), + isNull(copilotAsyncToolCalls.executionStartedAt), + isNull(copilotAsyncToolCalls.clientWorkflowExecutionId), + inArray(copilotAsyncToolCalls.status, [ + ASYNC_TOOL_STATUS.running, + ASYNC_TOOL_STATUS.delivered, + ]) + ) + : undefined ) ) ) diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index 82671cb137c..dfb4d04b14d 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -634,7 +634,10 @@ async function resolveAgentContexts(params: { { userId, workspaceId, organizationId, chatId }, resource.workspaceId ) - : { workspaceId: workspaceId! } + : workspaceId + ? { workspaceId } + : null + if (!target) return null const ctx = await withWorkspaceInvocationScope( { workspaceId: target.workspaceId, organizationId }, () => diff --git a/apps/sim/lib/mothership/content-delivery/registry-gate.test.ts b/apps/sim/lib/mothership/content-delivery/registry-gate.test.ts index 61fcec2f5da..2eea660c1d7 100644 --- a/apps/sim/lib/mothership/content-delivery/registry-gate.test.ts +++ b/apps/sim/lib/mothership/content-delivery/registry-gate.test.ts @@ -135,6 +135,23 @@ describe('createRegistryContentGate', () => { expect(await gate.admit([item('config', exact(SECRET))])).toEqual([false]) }) + it('tracks opaque workbench downloads without relaxing model or excerpt admission', async () => { + const registry = newRegistry() + const gate = createRegistryContentGate({ + registry, + actorUserId: 'user-1', + destination: 'workbench', + }) + expect(await gate.admit([item('download', exact(SECRET), 'opaque')])).toEqual([true]) + expect(modelView(SECRET, registry)).not.toContain(SECRET) + expect(await gate.admit([item('snippet', exact(SECRET), 'excerpt')])).toEqual([false]) + expect( + await createRegistryContentGate({ actorUserId: 'user-1', destination: 'workbench' }).admit([ + item('download', exact(SECRET), 'opaque'), + ]) + ).toEqual([false]) + }) + it('refuses an unknown item without latching the registry', async () => { const registry = newRegistry() const gate = createRegistryContentGate({ registry, actorUserId: 'user-1' }) diff --git a/apps/sim/lib/mothership/content-delivery/registry-gate.ts b/apps/sim/lib/mothership/content-delivery/registry-gate.ts index 6dc499c4324..ba6bd14fa90 100644 --- a/apps/sim/lib/mothership/content-delivery/registry-gate.ts +++ b/apps/sim/lib/mothership/content-delivery/registry-gate.ts @@ -23,6 +23,8 @@ export interface RegistryContentGateScope { * anything persisted from it stays unknown. Only a direct single-file download takes it (D7). */ unknown?: 'refuse' | 'taint' + /** Private workbench downloads persist imported provenance; opaque bytes never go directly to the model. */ + destination?: 'model' | 'workbench' } /** @@ -56,7 +58,11 @@ export function createRegistryContentGate(scope: RegistryContentGateScope): Cont return true } if (provenance.entries.length === 0) return true - if (source.view === 'opaque' || source.view === 'excerpt') return refuse(source, 'opaque') + if ( + source.view === 'excerpt' || + (source.view === 'opaque' && scope.destination !== 'workbench') + ) + return refuse(source, 'opaque') const registry = scope.registry if (!registry) return refuse(source, 'registry') /** A derived view that shows nothing needs no entry; `undefined` would mean the whole item. */ diff --git a/apps/sim/lib/mothership/persistence/tool-confirm/index.ts b/apps/sim/lib/mothership/persistence/tool-confirm/index.ts index 0119a37569c..dcabe6e2771 100644 --- a/apps/sim/lib/mothership/persistence/tool-confirm/index.ts +++ b/apps/sim/lib/mothership/persistence/tool-confirm/index.ts @@ -156,7 +156,7 @@ export async function waitForToolConfirmation( const checkDurableConfirmation = async (source: 'subscribe' | 'pubsub' | 'poll') => { if (options.executionScope) { try { - await revokeExpiredSimToolExecutions(options.executionScope) + await revokeExpiredSimToolExecutions({ ...options.executionScope, toolCallId }) } catch (error) { logger.warn('Could not verify retained tool execution ownership', { toolCallId, diff --git a/apps/sim/lib/mothership/persistence/tool-confirm/tool-confirm.test.ts b/apps/sim/lib/mothership/persistence/tool-confirm/tool-confirm.test.ts index a4d50c6f115..e0ea9178dae 100644 --- a/apps/sim/lib/mothership/persistence/tool-confirm/tool-confirm.test.ts +++ b/apps/sim/lib/mothership/persistence/tool-confirm/tool-confirm.test.ts @@ -308,7 +308,10 @@ describe('copilot orchestrator persistence', () => { }) await vi.advanceTimersByTimeAsync(5_000) expect(await waiting).toMatchObject({ status: 'error', message: 'Outcome unknown' }) - expect(revokeExpiredSimToolExecutions).toHaveBeenCalledWith(scope) + expect(revokeExpiredSimToolExecutions).toHaveBeenCalledWith({ + ...scope, + toolCallId: 'tool-1', + }) } finally { vi.useRealTimers() } diff --git a/apps/sim/lib/mothership/request/application/abandoned-runs.integration.ts b/apps/sim/lib/mothership/request/application/abandoned-runs.integration.ts new file mode 100644 index 00000000000..e759910a523 --- /dev/null +++ b/apps/sim/lib/mothership/request/application/abandoned-runs.integration.ts @@ -0,0 +1,838 @@ +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +/** + * Sim's own takeover of a Chat run whose controller died with no browser attached (a deploy + * stopping the process mid-tool), against real Redis and PostgreSQL through the production + * takeover, reconnect route and chat lifecycle. A local HTTP server stands in for the worker: + * it answers the new controller's re-attach by re-handing the parked call, as its + * duplicate-send path does, then answers the tool resume that follows. + */ +import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock' +import { envFlagsMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => { + const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') + const { createServer } = await import('node:http') + const worker = { + requests: [] as Array<{ path: string; body: Record }>, + /** SSE frames per worker path, per stream: other suites' runs never get this worker's reply. */ + replies: new Map>(), + } + const server = createServer(async (request, response) => { + let raw = '' + for await (const chunk of request) raw += chunk + const body = raw ? JSON.parse(raw) : {} + const streamId = String(body.messageId ?? body.streamId ?? '') + const path = request.url ?? '' + if (path === '/api/streams/explicit-abort') { + worker.requests.push({ path, body }) + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ settled: true })) + return + } + const frames = worker.replies.get(streamId)?.[path] + if (!frames) { + response.writeHead(404, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: 'Run not found' })) + return + } + worker.requests.push({ path, body }) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const frame of frames) response.write(`data: ${JSON.stringify(frame)}\n\n`) + response.end('data: [DONE]\n\n') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as { port: number } + const url = readTestRedisUrl() + const inheritedEnv = { + REDIS_URL: process.env.REDIS_URL, + SIM_AGENT_API_URL: process.env.SIM_AGENT_API_URL, + } + if (url) process.env.REDIS_URL = url + process.env.SIM_AGENT_API_URL = `http://127.0.0.1:${port}` + return { redisUrl: url, inheritedEnv, worker: Object.assign(worker, { server }) } +}) + +vi.mock('@/lib/auth', () => authMock) +vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) +/** + * Recovery's own workspace re-check runs only after authorization passed; `beforeRecheck` lets + * a test change real access at exactly that point. + */ +const { workspacePermission } = vi.hoisted(() => ({ + workspacePermission: { + rechecks: 0, + beforeRecheck: undefined as (() => Promise) | undefined, + }, +})) +vi.mock('@/lib/workspaces/permissions/utils', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + getUserEntityPermissions: async ( + ...args: Parameters + ) => { + workspacePermission.rechecks++ + await workspacePermission.beforeRecheck?.() + return original.getUserEntityPermissions(...args) + }, + } +}) + +import { db } from '@sim/db' +import { + copilotAsyncToolCalls, + copilotChats, + copilotMessages, + copilotRuns, + member, + organization, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import { and, eq, sql } from 'drizzle-orm' +import { NextRequest } from 'next/server' +import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis' +import { + EXHAUSTED_SIM_TOOL_MESSAGE, + SIM_TOOL_EXECUTION_MAX_ATTEMPTS, +} from '@/lib/mothership/async-runs/execution-lease' +import { + ABANDONED_RUN_CURSOR_KEY, + findAbandonedRecoverableRuns, + REVOKED_RUN_ERROR, +} from '@/lib/mothership/async-runs/orphaned-runs' +import { + claimToolExecution, + claimWorkflowToolExecution, + prepareWorkbenchAccess, + recordSimSandboxProcess, + revokeExhaustedSimToolExecutions, + revokeExpiredSimToolExecutions, + upsertAsyncToolCall, +} from '@/lib/mothership/async-runs/repository' +import { createTrustedCopilotPrincipal } from '@/lib/mothership/auth/application-delegation' +import { chatPubSub } from '@/lib/mothership/chat-status' +import { takeOverAbandonedRuns } from '@/lib/mothership/request/application/abandoned-runs' +import { + RUN_TAKEOVER_AUDIENCE, + takeOverStreamRun, +} from '@/lib/mothership/request/application/recover-stream' +import { + createProviderToolCallIdentity, + scopeProviderToolCallId, +} from '@/lib/mothership/request/go/tool-call-identity' +import { chatStreamLockKey } from '@/lib/mothership/request/session/controller-lease' +import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key' +import { GET as streamGET } from '@/app/api/copilot/chat/stream/route' + +const ownerId = generateId() +const userId = generateId() +const workspaceId = generateId() +const organizationId = generateId() +const chatIds: string[] = [] +const TOOL_NAME = 'get_credentials' +const TOOL_ARGS = {} + +function redis() { + const client = getRedisClient() + if (!client) throw new Error('The integration suite requires TEST_REDIS_URL') + return client +} + +function frameFor(streamId: string, chatId: string) { + return (seq: number, type: string, payload: unknown) => ({ + v: 1, + type, + seq, + ts: new Date().toISOString(), + stream: { streamId, chatId }, + payload, + }) +} + +/** + * A run whose controller died while its Sim tool was executing: no controller holds the + * chat, and the tool's execution lease lapsed with no result recorded. + */ +async function runAbandonedMidTool( + options: { attempts?: number; leaseLive?: boolean; toolName?: string; runId?: string } = {} +) { + const chatId = generateId() + chatIds.push(chatId) + const streamId = generateId() + const runId = options.runId ?? generateId() + const request = { message: 'Run the report', userId, messageId: streamId, chatId, workspaceId } + await db + .insert(copilotChats) + .values({ id: chatId, userId, workspaceId, type: 'mothership', conversationId: streamId }) + await db.insert(copilotMessages).values({ + chatId, + messageId: streamId, + role: 'user', + streamId, + seq: 0, + content: { id: streamId, role: 'user', content: request.message }, + }) + await db.insert(copilotRuns).values({ + id: runId, + executionId: generateId(), + chatId, + userId, + workspaceId, + streamId, + status: 'paused_waiting_for_tool', + toolExecutionVersion: 2, + requestContext: { + requestId: generateId(), + controllerToken: `${streamId}\n${generateId()}`, + recovery: { + kind: 'interactive_stream', + request, + goRoute: '/api/mothership', + clientToolPickupExpected: false, + }, + }, + }) + const toolCallId = scopeProviderToolCallId('sim-call', createProviderToolCallIdentity(runId)) + await upsertAsyncToolCall({ + runId, + toolCallId, + toolName: options.toolName ?? TOOL_NAME, + args: TOOL_ARGS, + }) + expect( + await claimToolExecution({ toolCallId, runId, userId, ownerToken: 'dead-controller' }) + ).toEqual({ outcome: 'claimed' }) + await db + .update(copilotAsyncToolCalls) + .set({ + executionAttempts: options.attempts ?? 1, + ...(options.leaseLive + ? {} + : { executionLeaseExpiresAt: sql`clock_timestamp() - interval '1 second'` }), + }) + .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + /** Its controller last wrote the run before it died, longer ago than a chat lock lives. */ + await db + .update(copilotRuns) + .set({ updatedAt: sql`now() - interval '2 minutes'` }) + .where(eq(copilotRuns.id, runId)) + + const frame = frameFor(streamId, chatId) + const text = 'Running the report. ' + worker.replies.set(streamId, { + '/api/mothership': [ + frame(1, 'session', { kind: 'start' }), + frame(2, 'text', { channel: 'assistant', text, textOffset: 0 }), + frame(3, 'tool', { + phase: 'call', + toolCallId: 'sim-call', + toolName: TOOL_NAME, + executor: 'sim', + mode: 'async', + arguments: TOOL_ARGS, + }), + frame(4, 'run', { + kind: 'checkpoint_pause', + checkpointId: generateId(), + executionId: generateId(), + runId: generateId(), + pendingToolCallIds: ['sim-call'], + }), + ], + '/api/tools/resume': [ + frame(5, 'text', { channel: 'assistant', text: 'done', textOffset: text.length }), + frame(6, 'complete', { status: 'complete', textLength: text.length + 4 }), + ], + }) + return { chatId, streamId, runId, toolCallId } +} + +async function storedRun(runId: string) { + const [run] = await db + .select({ + status: copilotRuns.status, + error: copilotRuns.error, + requestContext: copilotRuns.requestContext, + }) + .from(copilotRuns) + .where(eq(copilotRuns.id, runId)) + return run +} + +async function storedTool(toolCallId: string) { + const [tool] = await db + .select({ + status: copilotAsyncToolCalls.status, + attempts: copilotAsyncToolCalls.executionAttempts, + ownerToken: copilotAsyncToolCalls.executionOwnerToken, + error: copilotAsyncToolCalls.error, + }) + .from(copilotAsyncToolCalls) + .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + return tool +} + +async function waitForTerminal(runId: string) { + let status: string | undefined + for (let attempt = 0; attempt < 200; attempt++) { + status = (await storedRun(runId))?.status + if (status === 'complete' || status === 'error' || status === 'cancelled') return status + await sleep(100) + } + return status +} + +function requestsFor(streamId: string, path: string) { + return worker.requests.filter( + (request) => + request.path === path && (request.body.messageId ?? request.body.streamId) === streamId + ) +} + +function resumedResult(streamId: string) { + const resumes = requestsFor(streamId, '/api/tools/resume') + expect(resumes).toHaveLength(1) + const results = toArray(resumes[0].body.results).map((result) => toRecord(result)) + expect(results).toHaveLength(1) + expect(results[0].callId).toBe('sim-call') + return results[0] +} + +function expectResumedSuccess(streamId: string) { + const result = resumedResult(streamId) + expect(result.success, JSON.stringify(result.data)).toBe(true) +} + +/** Runs whether or not the suite does, so a skipped suite never leaks the worker or env. */ +afterAll(async () => { + await new Promise((resolve) => worker.server.close(() => resolve())) + for (const [key, value] of Object.entries(inheritedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +}) + +describe.runIf(Boolean(redisUrl))('taking over a run whose controller died', () => { + beforeAll(async () => { + const now = new Date() + for (const id of [ownerId, userId]) { + await db.insert(user).values({ + id, + name: 'Run takeover fixture', + email: `${id}@run-takeover.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + } + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Run takeover fixture', + ownerId, + billedAccountUserId: ownerId, + }) + await db + .insert(organization) + .values({ id: organizationId, name: 'Run takeover fixture', slug: organizationId }) + await db + .insert(member) + .values({ id: generateId(), organizationId, userId: ownerId, role: 'owner' }) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: userId }, + session: { id: generateId() }, + }) + }) + + beforeEach(async () => { + worker.requests.length = 0 + resetEnvFlagsMock() + featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false) + workspacePermission.rechecks = 0 + workspacePermission.beforeRecheck = undefined + await redis().del(ABANDONED_RUN_CURSOR_KEY) + await db + .insert(permissions) + .values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'write', + }) + .onConflictDoNothing() + }) + + afterAll(async () => { + for (const chatId of chatIds) { + await db.delete(copilotMessages).where(eq(copilotMessages.chatId, chatId)) + await db.delete(copilotRuns).where(eq(copilotRuns.chatId, chatId)) + await db.delete(copilotChats).where(eq(copilotChats.id, chatId)) + } + await db.delete(permissions).where(eq(permissions.entityId, workspaceId)) + await db.delete(member).where(eq(member.organizationId, organizationId)) + await db.delete(organization).where(eq(organization.id, organizationId)) + await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) + for (const id of [ownerId, userId]) await db.delete(user).where(eq(user.id, id)) + chatPubSub?.dispose() + await closeRedisConnection() + }) + + it('re-runs the tool its dead controller was executing and finishes the run', async () => { + const { streamId, runId, toolCallId } = await runAbandonedMidTool() + + await takeOverAbandonedRuns() + + expect(await waitForTerminal(runId)).toBe('complete') + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(1) + expectResumedSuccess(streamId) + const tool = await storedTool(toolCallId) + expect(tool.status).toBe('completed') + expect(tool.attempts).toBe(2) + expect(tool.ownerToken).not.toBe('dead-controller') + }) + + it('never takes over a run whose controller still holds its chat', async () => { + const { chatId, streamId, runId, toolCallId } = await runAbandonedMidTool() + const before = await storedRun(runId) + const lock = String(toRecord(before.requestContext).controllerToken) + await redis().set(chatStreamLockKey(chatId), lock, 'EX', 60) + try { + await takeOverAbandonedRuns() + await sleep(500) + + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + expect(await storedRun(runId)).toEqual(before) + expect((await storedTool(toolCallId)).attempts).toBe(1) + } finally { + await redis().del(chatStreamLockKey(chatId)) + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('waits for a still-leased Sim tool to lapse before taking the run over', async () => { + const { streamId, runId, toolCallId } = await runAbandonedMidTool({ leaseLive: true }) + + await takeOverAbandonedRuns() + await sleep(500) + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + expect((await storedTool(toolCallId)).attempts).toBe(1) + + await db + .update(copilotAsyncToolCalls) + .set({ executionLeaseExpiresAt: sql`clock_timestamp() - interval '1 second'` }) + .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + await takeOverAbandonedRuns() + + expect(await waitForTerminal(runId)).toBe('complete') + expectResumedSuccess(streamId) + expect((await storedTool(toolCallId)).attempts).toBe(2) + }) + + it('starts one controller when a browser reconnect races the takeover', async () => { + const { streamId, runId, toolCallId } = await runAbandonedMidTool() + + const [reconnect] = await Promise.all([ + streamGET( + new NextRequest( + `http://localhost:3000/api/copilot/chat/stream?streamId=${streamId}&after=0` + ), + { params: Promise.resolve({}) } + ), + takeOverAbandonedRuns(), + ]) + await reconnect.body?.cancel() + + expect(await waitForTerminal(runId)).toBe('complete') + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(1) + expectResumedSuccess(streamId) + expect((await storedTool(toolCallId)).attempts).toBe(2) + }) + + it('settles the run as an error and stops the worker once its user lost access', async () => { + const { chatId, streamId, runId, toolCallId } = await runAbandonedMidTool() + await db + .delete(permissions) + .where(and(eq(permissions.userId, userId), eq(permissions.entityId, workspaceId))) + + await takeOverAbandonedRuns() + + const run = await storedRun(runId) + expect(run.status).toBe('error') + expect(run.error).toBe(REVOKED_RUN_ERROR) + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + expect(requestsFor(streamId, '/api/streams/explicit-abort')).toHaveLength(1) + expect((await storedTool(toolCallId)).attempts).toBe(1) + const [chat] = await db + .select({ conversationId: copilotChats.conversationId }) + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + expect(chat.conversationId).toBeNull() + }) + + it('fails a tool that already died with its executor every time it may run', async () => { + const { streamId, runId, toolCallId } = await runAbandonedMidTool({ + attempts: SIM_TOOL_EXECUTION_MAX_ATTEMPTS, + }) + + await takeOverAbandonedRuns() + + expect(await waitForTerminal(runId)).toBe('complete') + const result = resumedResult(streamId) + expect(result.success).toBe(false) + expect(JSON.stringify(result)).toContain(EXHAUSTED_SIM_TOOL_MESSAGE) + const tool = await storedTool(toolCallId) + expect(tool.status).toBe('failed') + expect(tool.attempts).toBe(SIM_TOOL_EXECUTION_MAX_ATTEMPTS) + }) + + /** + * A workflow tool reaches the Sim executor through the browser-or-server pickup race; the + * dead controller's server run left it claimed, so only the server may pick it up again. + */ + it('lets only the server pick up an interrupted workflow run again', async () => { + const { runId, toolCallId } = await runAbandonedMidTool({ toolName: 'run_workflow' }) + try { + expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'client')).toBeNull() + expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).not.toBeNull() + expect( + await claimToolExecution({ toolCallId, runId, userId, ownerToken: 'successor' }) + ).toEqual({ outcome: 'claimed' }) + expect(await storedTool(toolCallId)).toMatchObject({ attempts: 2, ownerToken: 'successor' }) + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('fails an exhausted workflow run before a takeover offers it to anyone', async () => { + const { runId, toolCallId } = await runAbandonedMidTool({ + toolName: 'run_workflow', + attempts: SIM_TOOL_EXECUTION_MAX_ATTEMPTS, + }) + try { + expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).toBeNull() + await revokeExhaustedSimToolExecutions(runId) + expect(await storedTool(toolCallId)).toMatchObject({ + status: 'failed', + error: EXHAUSTED_SIM_TOOL_MESSAGE, + }) + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('reaches an abandoned run behind live ones, examining a bounded window per scan', async () => { + /** The lowest ids, so the scan meets the two live runs before the abandoned one. */ + const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000000${n}`) + const live = [ + await runAbandonedMidTool({ runId: ids[0] }), + await runAbandonedMidTool({ runId: ids[1] }), + ] + const abandoned = await runAbandonedMidTool({ runId: ids[2] }) + for (const run of live) { + await redis().set(chatStreamLockKey(run.chatId), `${run.streamId}\nlive`, 'EX', 60) + } + try { + const bound = { pageSize: 2, maxExamined: 2 } + expect(await findAbandonedRecoverableRuns(1, bound)).toEqual([]) + expect((await findAbandonedRecoverableRuns(1, bound)).map((run) => run.id)).toEqual([ + abandoned.runId, + ]) + } finally { + for (const run of [...live, abandoned]) { + await redis().del(chatStreamLockKey(run.chatId)) + await db + .update(copilotRuns) + .set({ status: 'cancelled' }) + .where(eq(copilotRuns.id, run.runId)) + } + } + }) + + it("hands a re-run the earlier attempt's workbench commands to stop first", async () => { + const { chatId, runId, toolCallId } = await runAbandonedMidTool({ leaseLive: true }) + const sessionKey = chatSandboxSessionKey(chatId) + try { + await recordSimSandboxProcess({ + toolCallId, + runId, + userId, + ownerToken: 'dead-controller', + process: { id: 'earlier-command', sandboxId: 'sandbox-1', sessionKey }, + }) + await db + .update(copilotAsyncToolCalls) + .set({ executionLeaseExpiresAt: sql`clock_timestamp() - interval '1 second'` }) + .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + const successor = { toolCallId, runId, userId, ownerToken: 'successor' } + expect(await claimToolExecution(successor)).toEqual({ outcome: 'claimed' }) + await recordSimSandboxProcess({ + ...successor, + process: { id: 'own-command', sandboxId: 'sandbox-1', sessionKey }, + }) + + const state = await prepareWorkbenchAccess({ ...successor, sessionKey }) + + expect(state.processes.map((process) => process.id)).toEqual(['earlier-command']) + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('claims no run once its caller is shutting down', async () => { + const { chatId, streamId, runId, toolCallId } = await runAbandonedMidTool() + const before = await storedRun(runId) + const shutdown = new AbortController() + shutdown.abort() + const principal = createTrustedCopilotPrincipal( + { userId, workspaceId, chatId, delegationId: `test:${runId}` }, + { audience: RUN_TAKEOVER_AUDIENCE, ttlMs: 60_000 } + ) + try { + await expect( + takeOverStreamRun.execute({ + principal, + input: { streamId }, + request: { headers: new Headers(), signal: shutdown.signal }, + }) + ).rejects.toThrow() + + expect(await storedRun(runId)).toEqual(before) + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + expect((await storedTool(toolCallId)).attempts).toBe(1) + expect(await redis().get(chatStreamLockKey(chatId))).toBeNull() + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('leaves a run recoverable when its takeover fails for a reason other than access', async () => { + /** Hosted recovery refuses a run saved without its billing admission. */ + setEnvFlags({ isHosted: true }) + const { streamId, runId } = await runAbandonedMidTool() + const before = await storedRun(runId) + try { + await takeOverAbandonedRuns() + + expect(await storedRun(runId)).toEqual(before) + expect(requestsFor(streamId, '/api/streams/explicit-abort')).toHaveLength(0) + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('re-claims a server workflow claim whose claimant died before starting it, once', async () => { + const chatId = generateId() + chatIds.push(chatId) + const runId = generateId() + const streamId = generateId() + await db.insert(copilotChats).values({ id: chatId, userId, workspaceId, type: 'mothership' }) + await db.insert(copilotRuns).values({ + id: runId, + executionId: generateId(), + chatId, + userId, + workspaceId, + streamId, + status: 'paused_waiting_for_tool', + toolExecutionVersion: 2, + }) + const toolCallId = scopeProviderToolCallId( + 'workflow-call', + createProviderToolCallIdentity(runId) + ) + await upsertAsyncToolCall({ runId, toolCallId, toolName: 'run_workflow', args: {} }) + await db + .update(copilotAsyncToolCalls) + .set({ status: 'running' }) + .where(eq(copilotAsyncToolCalls.toolCallId, toolCallId)) + try { + /** The dead controller's server fallback claimed the call, then died before starting it. */ + expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).not.toBeNull() + + expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'client')).toBeNull() + expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).not.toBeNull() + const successor = { toolCallId, runId, userId, ownerToken: 'successor' } + expect(await claimToolExecution(successor)).toEqual({ outcome: 'claimed' }) + expect(await claimToolExecution({ ...successor, ownerToken: 'late-claimant' })).toEqual({ + outcome: 'existing', + }) + expect(await claimWorkflowToolExecution(toolCallId, generateId(), 'sim')).toBeNull() + expect(await storedTool(toolCallId)).toMatchObject({ attempts: 1, ownerToken: 'successor' }) + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('adopts nothing while the takeover kill switch is on', async () => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation( + async (flag) => flag === 'mothership-abandoned-run-takeover-disabled' + ) + const { streamId, runId, toolCallId } = await runAbandonedMidTool() + const before = await storedRun(runId) + try { + await takeOverAbandonedRuns() + + expect(await storedRun(runId)).toEqual(before) + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + expect((await storedTool(toolCallId)).attempts).toBe(1) + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it("spares a call awaiting its re-run when a sibling's wait or workbench access revokes", async () => { + const { + chatId, + runId, + toolCallId: interrupted, + } = await runAbandonedMidTool({ leaseLive: true }) + const sessionKey = chatSandboxSessionKey(chatId) + const sibling = scopeProviderToolCallId('sibling-call', createProviderToolCallIdentity(runId)) + try { + await recordSimSandboxProcess({ + toolCallId: interrupted, + runId, + userId, + ownerToken: 'dead-controller', + process: { id: 'interrupted-command', sandboxId: 'sandbox-1', sessionKey }, + }) + await db + .update(copilotAsyncToolCalls) + .set({ executionLeaseExpiresAt: sql`clock_timestamp() - interval '1 second'` }) + .where(eq(copilotAsyncToolCalls.toolCallId, interrupted)) + await upsertAsyncToolCall({ runId, toolCallId: sibling, toolName: TOOL_NAME, args: {} }) + const siblingOwner = { toolCallId: sibling, runId, userId, ownerToken: 'sibling' } + expect(await claimToolExecution(siblingOwner)).toEqual({ outcome: 'claimed' }) + + /** The re-handed sibling runs first: its wait and its workbench access both revoke. */ + await revokeExpiredSimToolExecutions({ runId, userId, toolCallId: sibling }) + const state = await prepareWorkbenchAccess({ ...siblingOwner, sessionKey }) + + expect(state.handlersPending).toBe(false) + expect(state.processes.map((process) => process.id)).toEqual(['interrupted-command']) + expect((await storedTool(interrupted)).status).toBe('running') + expect( + await claimToolExecution({ + toolCallId: interrupted, + runId, + userId, + ownerToken: 'successor', + }) + ).toEqual({ outcome: 'claimed' }) + } finally { + await db.update(copilotRuns).set({ status: 'cancelled' }).where(eq(copilotRuns.id, runId)) + } + }) + + it('settles the run as revoked when access is lost after authorization passed', async () => { + const { streamId, runId, toolCallId } = await runAbandonedMidTool() + /** The user is removed from the workspace after authorization admitted them. */ + workspacePermission.beforeRecheck = async () => { + await db + .delete(permissions) + .where(and(eq(permissions.userId, userId), eq(permissions.entityId, workspaceId))) + } + + await takeOverAbandonedRuns() + + expect(workspacePermission.rechecks).toBe(1) + + const run = await storedRun(runId) + expect(run.status).toBe('error') + expect(run.error).toBe(REVOKED_RUN_ERROR) + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + expect(requestsFor(streamId, '/api/streams/explicit-abort')).toHaveLength(1) + expect((await storedTool(toolCallId)).attempts).toBe(1) + }) + + describe('an organization Assistant run', () => { + async function runAbandonedInOrganization() { + const chatId = generateId() + chatIds.push(chatId) + const streamId = generateId() + const runId = generateId() + const request = { + message: 'Find the report', + userId, + messageId: streamId, + chatId, + organizationId, + mode: 'assistant', + } + await db.insert(copilotChats).values({ + id: chatId, + userId, + organizationId, + type: 'mothership', + conversationId: streamId, + }) + await db.insert(copilotRuns).values({ + id: runId, + executionId: generateId(), + chatId, + userId, + organizationId, + streamId, + status: 'active', + toolExecutionVersion: 2, + updatedAt: new Date(Date.now() - 120_000), + requestContext: { + requestId: generateId(), + controllerToken: `${streamId}\n${generateId()}`, + recovery: { + kind: 'interactive_stream', + request, + goRoute: '/api/mothership', + clientToolPickupExpected: false, + }, + }, + }) + const frame = frameFor(streamId, chatId) + const text = 'Here is the report.' + worker.replies.set(streamId, { + '/api/mothership': [ + frame(1, 'session', { kind: 'start' }), + frame(2, 'text', { channel: 'assistant', text, textOffset: 0 }), + frame(3, 'complete', { status: 'complete', textLength: text.length }), + ], + }) + return { streamId, runId } + } + + it('takes over a member’s run as that member', async () => { + await db.insert(member).values({ id: generateId(), organizationId, userId, role: 'member' }) + const { streamId, runId } = await runAbandonedInOrganization() + try { + await takeOverAbandonedRuns() + + expect(await waitForTerminal(runId)).toBe('complete') + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(1) + } finally { + await db + .delete(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + } + }) + + it('settles a removed member’s run as revoked', async () => { + const { streamId, runId } = await runAbandonedInOrganization() + + await takeOverAbandonedRuns() + + const run = await storedRun(runId) + expect(run.status).toBe('error') + expect(run.error).toBe(REVOKED_RUN_ERROR) + expect(requestsFor(streamId, '/api/mothership')).toHaveLength(0) + expect(requestsFor(streamId, '/api/streams/explicit-abort')).toHaveLength(1) + }) + }) +}) diff --git a/apps/sim/lib/mothership/request/application/abandoned-runs.ts b/apps/sim/lib/mothership/request/application/abandoned-runs.ts new file mode 100644 index 00000000000..5b0dd9dcc34 --- /dev/null +++ b/apps/sim/lib/mothership/request/application/abandoned-runs.ts @@ -0,0 +1,137 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { env } from '@/lib/core/config/env' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { + findAbandonedRecoverableRuns, + settleRunWithoutAccess, + type UnownedRun, +} from '@/lib/mothership/async-runs/orphaned-runs' +import { + createTrustedCopilotPrincipal, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/mothership/auth/application-delegation' +import { + RUN_TAKEOVER_AUDIENCE, + takeOverStreamRun, +} from '@/lib/mothership/request/application/recover-stream' +import { requestExplicitStreamAbort } from '@/lib/mothership/request/session/explicit-abort' + +const logger = createLogger('MothershipAbandonedRuns') + +/** A dead controller's chat lock lapses within a minute; this finds its run soon after. */ +const TAKEOVER_INTERVAL_MS = 20_000 +const TAKEOVER_BATCH_SIZE = 20 +const DELEGATION_TTL_MS = 60_000 + +let timer: ReturnType | undefined +let running = false +/** Aborted on shutdown; a takeover checks it at its commit point, the controller claim. */ +const shutdown = new AbortController() + +/** + * Resumes the run as its user. Only an access denial settles the run as an error and stops + * the worker: the authorization refusing the user or chat, or recovery finding the user's + * workspace access gone since. Any other refusal leaves it recoverable, as a failed reconnect + * does. + */ +async function takeOver(run: UnownedRun): Promise { + const delegationId = `takeover:${run.id}` + const delegation = { audience: RUN_TAKEOVER_AUDIENCE, ttlMs: DELEGATION_TTL_MS } + const { userId, chatId, workspaceId, organizationId } = run + const principal = organizationId + ? createTrustedOrganizationCopilotPrincipal( + { userId, organizationId, chatId, delegationId }, + delegation + ) + : workspaceId + ? createTrustedCopilotPrincipal({ userId, workspaceId, chatId, delegationId }, delegation) + : null + if (!principal) return + const input = { streamId: run.streamId } + try { + await takeOverStreamRun.authorize({ principal, input }) + } catch (error) { + const refusal = asOrchestrationError(error)?.code + if (refusal !== 'forbidden' && refusal !== 'not_found') throw error + await settleWithoutAccess(run) + return + } + try { + await takeOverStreamRun.execute({ + principal, + input, + request: { headers: new Headers(), signal: shutdown.signal }, + }) + } catch (error) { + if (!(asOrchestrationError(error) instanceof NoWorkspaceAccessError)) throw error + await settleWithoutAccess(run) + } +} + +async function settleWithoutAccess(run: UnownedRun): Promise { + if (!(await settleRunWithoutAccess(run))) return + logger.info('Settled an abandoned run its user may no longer continue', { + runId: run.id, + streamId: run.streamId, + }) + await requestExplicitStreamAbort({ + streamId: run.streamId, + userId: run.userId, + chatId: run.chatId, + }).catch((error) => + logger.warn('Worker stop for a settled abandoned run was not delivered', { + streamId: run.streamId, + error: getErrorMessage(error), + }) + ) +} + +/** + * Takes over each interactive run whose controller is gone, as a browser reconnect would, + * so a run outlives the Sim process driving it (a deploy or a crash) with no browser open. + * Every Sim process looks; the chat lock and the controller token admit exactly one. + */ +export async function takeOverAbandonedRuns(): Promise { + if (running) return + running = true + try { + if (await isFeatureEnabled('mothership-abandoned-run-takeover-disabled')) return + for (const run of await findAbandonedRecoverableRuns(TAKEOVER_BATCH_SIZE)) { + if (shutdown.signal.aborted) return + await takeOver(run).catch((error) => + logger.warn('Abandoned run could not be taken over; a later round retries', { + runId: run.id, + streamId: run.streamId, + error: getErrorMessage(error), + }) + ) + } + } finally { + running = false + } +} + +/** + * Needs Redis, whose chat locks are the only proof a run is abandoned. Stops on shutdown, so a + * draining process adopts no run it is about to abandon. + */ +export function startAbandonedRunTakeover(): void { + if (!env.COPILOT_API_KEY || !env.REDIS_URL || timer) return + timer = setInterval(() => { + void takeOverAbandonedRuns().catch((error) => + logger.warn('Abandoned run scan failed', { error: getErrorMessage(error) }) + ) + }, TAKEOVER_INTERVAL_MS) + timer.unref() + process.once('SIGTERM', stopAbandonedRunTakeover) + process.once('SIGINT', stopAbandonedRunTakeover) +} + +/** Ends the scans and refuses every takeover not yet committed, including one in flight. */ +function stopAbandonedRunTakeover(): void { + clearInterval(timer) + shutdown.abort() +} diff --git a/apps/sim/lib/mothership/request/application/recover-stream.ts b/apps/sim/lib/mothership/request/application/recover-stream.ts index 0fd115575b0..c5605556a3e 100644 --- a/apps/sim/lib/mothership/request/application/recover-stream.ts +++ b/apps/sim/lib/mothership/request/application/recover-stream.ts @@ -1,4 +1,10 @@ -import type { SessionPrincipal } from '@sim/auth/principal' +import { + type DelegatedPrincipal, + type OrganizationDelegatedPrincipal, + type Principal, + resolvePrincipalSubjectUserId, + type SessionPrincipal, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -6,11 +12,15 @@ import { resolveBillingAttribution, resolveOrganizationBillingAttribution, } from '@/lib/billing/core/attribution' -import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineWorkspaceOperation, NoWorkspaceAccessError } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { isHosted } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository' +import { + getLatestRunForStream, + hasLiveSimToolExecution, + revokeExhaustedSimToolExecutions, +} from '@/lib/mothership/async-runs/repository' import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case' import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' import { buildOnComplete, buildOnError } from '@/lib/mothership/chat/completion' @@ -36,6 +46,33 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipStreamRecovery') +/** The delegation audience Sim's own takeover of an abandoned run acts under. */ +export const RUN_TAKEOVER_AUDIENCE = 'sim:copilot-run-takeover' + +type StreamRunContext = Awaited> & { + run: NonNullable>> +} + +async function resolveStreamRunContext({ + principal, + input, +}: { + principal: Principal + input: { streamId: string } +}): Promise { + const userId = resolvePrincipalSubjectUserId(principal) + const run = userId ? await getLatestRunForStream(input.streamId, userId) : null + if (!run?.chatId) throw new OrchestrationError('not_found', 'Stream not found') + const chat = await resolveOwnedChatContext(principal, run.chatId) + if ( + (run.workspaceId ?? null) !== (chat.workspaceId ?? null) || + (run.organizationId ?? null) !== (chat.organizationId ?? null) + ) + throw new OrchestrationError('not_found', 'Stream not found') + return { ...chat, run } +} + +/** The browser's reconnect, which takes over the run when its controller is gone. */ export const readChatStream = defineAuthorizedChatUseCase({ operation: defineWorkspaceOperation({ id: 'mothership.runs.reconnect', @@ -50,171 +87,214 @@ export const readChatStream = defineAuthorizedChatUseCase({ capability: 'copilot.use', principalKinds: ['session'], }), - async resolveContext({ + resolveContext: ({ principal, input, }: { principal: SessionPrincipal input: { streamId: string } - }) { - const run = await getLatestRunForStream(input.streamId, principal.userId) - if (!run?.chatId) throw new OrchestrationError('not_found', 'Stream not found') - const chat = await resolveOwnedChatContext(principal, run.chatId) - if ( - (run.workspaceId ?? null) !== (chat.workspaceId ?? null) || - (run.organizationId ?? null) !== (chat.organizationId ?? null) - ) - throw new OrchestrationError('not_found', 'Stream not found') - return { ...chat, run } - }, + }) => resolveStreamRunContext({ principal, input }), authorizationOptions: {}, - async execute({ context }) { - const { run, chatId, userId, workspaceId, organizationId } = context - if (isTerminalStreamStatus(run.status)) return run - const saved = run.requestContext as Record | null - const config = StreamRecoveryConfigSchema.safeParse(saved?.recovery) - if (!config.success || typeof saved?.controllerToken !== 'string') return run - const intent = config.data.request + execute: ({ context }) => recoverStreamRun(context), +}) + +/** + * Sim's own takeover of a run whose controller is gone, acting as the run's user with no + * browser attached. It re-checks that user's current access exactly as a reconnect does. + */ +export const takeOverStreamRun = defineAuthorizedChatUseCase({ + operation: defineWorkspaceOperation({ + id: 'mothership.runs.takeover', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'copilot.use', + contentDelivery: 'none', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + organizationOperation: defineOrganizationOperation({ + id: 'mothership.runs.takeover', + contentDelivery: 'none', + minimumRole: 'member', + capability: 'copilot.use', + principalKinds: ['organization_delegated'], + delegationAudience: RUN_TAKEOVER_AUDIENCE, + delegatedServices: ['copilot'], + }), + resolveContext: ({ + principal, + input, + }: { + principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + input: { streamId: string } + }) => resolveStreamRunContext({ principal, input }), + authorizationOptions: { + delegation: { + audience: RUN_TAKEOVER_AUDIENCE, + isWithinScope: (principal, context) => + principal.serviceId === 'copilot' && + principal.workspaceId === context.workspaceId && + principal.resourceScope?.chatId === context.chatId, + }, + }, + execute: ({ context, request }) => recoverStreamRun(context, request?.signal), +}) + +/** + * Takes over the run when no controller holds its chat and none of its Sim tools is still + * running, then drives it on from the worker's re-attach. Anything else returns the run as is. + * An aborted `admission` declines the run before its controller is claimed. + */ +async function recoverStreamRun(context: StreamRunContext, admission?: AbortSignal) { + const { run, chatId, userId, workspaceId, organizationId } = context + if (isTerminalStreamStatus(run.status)) return run + const saved = run.requestContext as Record | null + const config = StreamRecoveryConfigSchema.safeParse(saved?.recovery) + if (!config.success || typeof saved?.controllerToken !== 'string') return run + const intent = config.data.request + if ( + intent.userId !== userId || + intent.workspaceId !== workspaceId || + intent.organizationId !== organizationId || + (organizationId && intent.mode !== context.mode) || + intent.chatId !== chatId || + intent.messageId !== run.streamId + ) { + throw new OrchestrationError('validation', 'Saved stream identity does not match its chat') + } + /** A dead owner's lease lapses within a minute; then its call can be run again. */ + if (await hasLiveSimToolExecution(run.id)) return run + const plan = planRecovery(saved.recoveryBackoff, Date.now()) + if (plan.kind === 'wait') return run + if (!(await acquirePendingChatStream(chatId, run.streamId, 0))) return run + const lease = getLocalChatStreamLease(chatId, run.streamId)! + try { + if (isHosted && !config.data.billingAdmission) + throw new OrchestrationError( + 'forbidden', + 'Hosted recovery is missing its original billing admission' + ) + const restoredAdmission = config.data.billingAdmission + ? restoreBillingAdmission(config.data.billingAdmission, { + userId, + workspaceId, + organizationId, + }) + : undefined + const [events, billingAttribution, userPermission] = await Promise.all([ + readEvents(run.streamId, '0'), + restoredAdmission + ? Promise.resolve(restoredAdmission.attribution) + : organizationId + ? resolveOrganizationBillingAttribution({ actorUserId: userId, organizationId }) + : resolveBillingAttribution({ actorUserId: userId, workspaceId: workspaceId! }), + workspaceId + ? getUserEntityPermissions(userId, 'workspace', workspaceId) + : Promise.resolve(undefined), + ]) + if (workspaceId && !userPermission) throw new NoWorkspaceAccessError() + /** + * A ring that lost its head is treated like an expired one: the controller starts + * from an empty context and re-attaches with an empty receipt, so the worker re-sends + * the whole response and re-hands its parked calls. Rebuilding from the tail would + * persist a truncated turn. Without a recovered event, numbering resumes past the + * stream's counter, which outlives unreadable entries still holding earlier seqs. + */ + const ringIntact = startsAtReplayHead(events[0]?.seq) + const recoveredEvents = ringIntact ? events : [] + const lastEvent = recoveredEvents.at(-1) + const resumeSeq = lastEvent ? lastEvent.seq : ((await getLatestSeq(run.streamId)) ?? 0) + /** + * Claim last: everything before it can fail without touching the run, so a takeover + * that cannot start neither spends the recovery budget nor refreshes the run, and + * an exhausted claim always reaches the terminal path below. + */ + await revokeExhaustedSimToolExecutions(run.id) + await assertChatStreamLease(lease) + /** The last point a shutting-down caller can still decline the run without owning it. */ + admission?.throwIfAborted() if ( - intent.userId !== userId || - intent.workspaceId !== workspaceId || - intent.organizationId !== organizationId || - (organizationId && intent.mode !== context.mode) || - intent.chatId !== chatId || - intent.messageId !== run.streamId - ) { - throw new OrchestrationError('validation', 'Saved stream identity does not match its chat') - } - const plan = planRecovery(saved.recoveryBackoff, Date.now()) - if (plan.kind === 'wait') return run - if (!(await acquirePendingChatStream(chatId, run.streamId, 0))) return run - const lease = getLocalChatStreamLease(chatId, run.streamId)! - try { - if (isHosted && !config.data.billingAdmission) - throw new OrchestrationError( - 'forbidden', - 'Hosted recovery is missing its original billing admission' - ) - const restoredAdmission = config.data.billingAdmission - ? restoreBillingAdmission(config.data.billingAdmission, { - userId, - workspaceId, - organizationId, - }) - : undefined - const [events, billingAttribution, userPermission] = await Promise.all([ - readEvents(run.streamId, '0'), - restoredAdmission - ? Promise.resolve(restoredAdmission.attribution) - : organizationId - ? resolveOrganizationBillingAttribution({ actorUserId: userId, organizationId }) - : resolveBillingAttribution({ actorUserId: userId, workspaceId: workspaceId! }), - workspaceId - ? getUserEntityPermissions(userId, 'workspace', workspaceId) - : Promise.resolve(undefined), - ]) - if (workspaceId && !userPermission) - throw new OrchestrationError('forbidden', 'Workspace access revoked') - /** - * A ring that lost its head is treated like an expired one: the controller starts - * from an empty context and re-attaches with an empty receipt, so the worker re-sends - * the whole response and re-hands its parked calls. Rebuilding from the tail would - * persist a truncated turn. Without a recovered event, numbering resumes past the - * stream's counter, which outlives unreadable entries still holding earlier seqs. - */ - const ringIntact = startsAtReplayHead(events[0]?.seq) - const recoveredEvents = ringIntact ? events : [] - const lastEvent = recoveredEvents.at(-1) - const resumeSeq = lastEvent ? lastEvent.seq : ((await getLatestSeq(run.streamId)) ?? 0) - /** - * Claim last: everything before it can fail without touching the run, so a takeover - * that cannot start neither spends the recovery budget nor refreshes the run, and - * an exhausted claim always reaches the terminal path below. - */ - await assertChatStreamLease(lease) - if ( - !(await claimRunController({ - runId: run.id, - chatId, - previousToken: saved.controllerToken, - token: lease.value, - recoveryBackoff: plan.backoff, - })) - ) { - await releasePendingChatStream(chatId, run.streamId, lease) - return (await getLatestRunForStream(run.streamId, userId)) ?? run - } - logger.info('Claimed stream run for recovery', { + !(await claimRunController({ runId: run.id, - streamId: run.streamId, - attempt: plan.backoff.attempts, - exhausted: plan.kind === 'exhausted', - }) - const requestId = typeof saved?.requestId === 'string' ? saved.requestId : generateId() - const completion = { chatId, - userMessageId: run.streamId, - requestId, - workspaceId, - organizationId, - userId, - requestMode: - intent.mode === 'plan' - ? ('plan' as const) - : intent.mode === 'assistant' - ? ('assistant' as const) - : ('agent' as const), - notifyWorkspaceStatus: true, - runController: { id: run.id, token: lease.value }, - } - const stream = createSSEStream({ + previousToken: saved.controllerToken, + token: lease.value, + recoveryBackoff: plan.backoff, + })) + ) { + await releasePendingChatStream(chatId, run.streamId, lease) + return (await getLatestRunForStream(run.streamId, userId)) ?? run + } + logger.info('Claimed stream run for recovery', { + runId: run.id, + streamId: run.streamId, + attempt: plan.backoff.attempts, + exhausted: plan.kind === 'exhausted', + }) + const requestId = typeof saved?.requestId === 'string' ? saved.requestId : generateId() + const completion = { + chatId, + userMessageId: run.streamId, + requestId, + workspaceId, + organizationId, + userId, + requestMode: + intent.mode === 'plan' + ? ('plan' as const) + : intent.mode === 'assistant' + ? ('assistant' as const) + : ('agent' as const), + notifyWorkspaceStatus: true, + runController: { id: run.id, token: lease.value }, + } + const stream = createSSEStream({ + userId, + workspaceId, + organizationId, + chatId, + streamId: run.streamId, + executionId: run.executionId, + runId: run.id, + requestId, + requestPayload: intent, + currentChat: null, + message: '', + titleModel: '', + resumeSeq, + ...(plan.kind === 'exhausted' ? { failure: new StreamRecoveryExhaustedError() } : {}), + orchestrateOptions: { userId, workspaceId, organizationId, chatId, - streamId: run.streamId, - executionId: run.executionId, runId: run.id, - requestId, - requestPayload: intent, - currentChat: null, - message: '', - titleModel: '', - resumeSeq, - ...(plan.kind === 'exhausted' ? { failure: new StreamRecoveryExhaustedError() } : {}), - orchestrateOptions: { - userId, - workspaceId, - organizationId, - chatId, - runId: run.id, - executionId: run.executionId, - workflowId: run.workflowId ?? undefined, - goRoute: config.data.goRoute, - billingAttribution, - userPermission: userPermission ?? undefined, - interactive: true, - autoExecuteTools: true, - clientToolPickupExpected: config.data.clientToolPickupExpected, - recovery: { - ...config.data, - streamId: run.streamId, - events: recoveredEvents.map(eventToStreamEvent), - }, - onComplete: buildOnComplete(completion), - onError: buildOnError(completion), + executionId: run.executionId, + workflowId: run.workflowId ?? undefined, + goRoute: config.data.goRoute, + billingAttribution, + userPermission: userPermission ?? undefined, + interactive: true, + autoExecuteTools: true, + clientToolPickupExpected: config.data.clientToolPickupExpected, + recovery: { + ...config.data, + streamId: run.streamId, + events: recoveredEvents.map(eventToStreamEvent), }, - }) - // Its lifecycle is detached, just like the original POST after a browser disconnect. - void stream - .cancel() - .catch((error) => - logger.warn('Recovered stream cleanup failed', { error: getErrorMessage(error) }) - ) - return run - } catch (error) { - await releasePendingChatStream(chatId, run.streamId, lease) - throw error - } - }, -}) + onComplete: buildOnComplete(completion), + onError: buildOnError(completion), + }, + }) + // Its lifecycle is detached, just like the original POST after a browser disconnect. + void stream + .cancel() + .catch((error) => + logger.warn('Recovered stream cleanup failed', { error: getErrorMessage(error) }) + ) + return run + } catch (error) { + await releasePendingChatStream(chatId, run.streamId, lease) + throw error + } +} diff --git a/apps/sim/lib/mothership/request/session/abort.test.ts b/apps/sim/lib/mothership/request/session/abort.test.ts index 1d6bbcd0ee9..cd007c07f18 100644 --- a/apps/sim/lib/mothership/request/session/abort.test.ts +++ b/apps/sim/lib/mothership/request/session/abort.test.ts @@ -101,6 +101,59 @@ describe('startAbortPoller heartbeat', () => { } }) + it('stops the controller once its lock lapsed while every renewal failed', async () => { + const controller = new AbortController() + const streamId = 'stream-partitioned' + const chatId = 'chat-partitioned' + redisConfigMockFns.mockExtendLock.mockRejectedValue(new Error('redis unreachable')) + + const interval = startAbortPoller(streamId, controller, { + chatId, + lease: { key: `copilot:chat-stream-lock:${chatId}`, value: `${streamId}\ncontroller-1` }, + }) + + try { + await vi.advanceTimersByTimeAsync(59_000) + expect(controller.signal.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(2_000) + expect(controller.signal.aborted).toBe(true) + expect(controller.signal.reason.name).toBe('StreamControllerSupersededError') + const attempts = redisConfigMockFns.mockExtendLock.mock.calls.length + await vi.advanceTimersByTimeAsync(10_000) + expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(attempts) + } finally { + clearInterval(interval) + } + }) + + it('times the lapse from when the lock was taken, not from when polling started', async () => { + const controller = new AbortController() + const streamId = 'stream-slow-setup' + const chatId = 'chat-slow-setup' + redisConfigMockFns.mockExtendLock.mockRejectedValue(new Error('redis unreachable')) + + const interval = startAbortPoller(streamId, controller, { + chatId, + lease: { + key: `copilot:chat-stream-lock:${chatId}`, + value: `${streamId}\ncontroller-1`, + acquiredAt: Date.now() - 50_000, + }, + }) + + try { + await vi.advanceTimersByTimeAsync(9_000) + expect(controller.signal.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(2_000) + expect(controller.signal.aborted).toBe(true) + expect(controller.signal.reason.name).toBe('StreamControllerSupersededError') + } finally { + clearInterval(interval) + } + }) + it('aborts the controller before clearing the marker so the marker is never observable as cleared while the signal is still unaborted', async () => { const controller = new AbortController() const streamId = 'stream-order-1' diff --git a/apps/sim/lib/mothership/request/session/abort.ts b/apps/sim/lib/mothership/request/session/abort.ts index b5c36fe3b22..8d32e0cf03d 100644 --- a/apps/sim/lib/mothership/request/session/abort.ts +++ b/apps/sim/lib/mothership/request/session/abort.ts @@ -227,14 +227,19 @@ export async function acquirePendingChatStream( }, async (span) => { const redis = getRedisClient() - const lease = { key: chatStreamLockKey(chatId), value: `${streamId}\n${generateId()}` } + const lease: ChatStreamLease = { + key: chatStreamLockKey(chatId), + value: `${streamId}\n${generateId()}`, + } span.setAttribute(TraceAttr.LockBackend, redis ? AbortBackend.Redis : AbortBackend.InProcess) if (redis) { const deadline = Date.now() + timeoutMs for (;;) { try { + const attemptedAt = Date.now() const acquired = await acquireLock(lease.key, lease.value, CHAT_STREAM_LOCK_TTL_SECONDS) if (acquired) { + lease.acquiredAt = attemptedAt registerPendingChatStream(chatId, streamId, lease) span.setAttribute(TraceAttr.LockAcquired, true) return true @@ -357,8 +362,14 @@ export function startAbortPoller( const chatId = options?.chatId const lease = options?.lease ?? (chatId ? getLocalChatStreamLease(chatId, streamId) : undefined) - let lastHeartbeatAt = Date.now() + /** The lock's TTL runs from its last confirmed write: the acquisition until a renewal lands. */ + let lastHeartbeatAt = lease?.acquiredAt ?? Date.now() let heartbeatOwnershipLost = false + const stopController = (message: string) => { + heartbeatOwnershipLost = true + abortController.abort(new StreamControllerSupersededError()) + logger.warn(message, { chatId, streamId, ...(requestId ? { requestId } : {}) }) + } return setInterval(() => { if (pollingStreams.has(streamId)) return @@ -388,13 +399,7 @@ export function startAbortPoller( const owned = await extendLock(lease.key, lease.value, CHAT_STREAM_LOCK_TTL_SECONDS) lastHeartbeatAt = Date.now() if (!owned) { - heartbeatOwnershipLost = true - abortController.abort(new StreamControllerSupersededError()) - logger.warn('Lost ownership of chat stream lock β€” stopping heartbeat', { - chatId, - streamId, - ...(requestId ? { requestId } : {}), - }) + stopController('Lost ownership of chat stream lock β€” stopping heartbeat') return } await refreshBufferTtl(streamId).catch((error) => { @@ -412,6 +417,9 @@ export function startAbortPoller( ...(requestId ? { requestId } : {}), error: toError(error).message, }) + /** Unrenewed this long, the lock has lapsed and another controller may own the run. */ + if (Date.now() - lastHeartbeatAt >= CHAT_STREAM_LOCK_TTL_SECONDS * 1000) + stopController('Chat stream lock lapsed while unrenewable β€” stopping this controller') } })() }, pollMs) diff --git a/apps/sim/lib/mothership/request/session/controller-lease.ts b/apps/sim/lib/mothership/request/session/controller-lease.ts index b7d89647a33..cc5e51f4fab 100644 --- a/apps/sim/lib/mothership/request/session/controller-lease.ts +++ b/apps/sim/lib/mothership/request/session/controller-lease.ts @@ -4,6 +4,8 @@ import { getRedisClient } from '@/lib/core/config/redis' export interface ChatStreamLease { key: string value: string + /** When the lock was taken (ms epoch, read before the write), so its TTL is known to run from then. */ + acquiredAt?: number } export class StreamControllerSupersededError extends Error { diff --git a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts new file mode 100644 index 00000000000..a9c4e7c77e1 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts @@ -0,0 +1,618 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import type { + OAuthAccessTokenPrincipal, + ResourceDelegatedPrincipal, + ResourceFileCopyScope, +} from '@sim/auth/principal' +import { db } from '@sim/db' +import { + copilotChats, + folder, + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + user, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { + createExecutorPrincipal, + createPersonalApiKeyPrincipal, + createSessionPrincipal, + createWorkspaceApiKeyPrincipal, +} from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { createProjectFileFolder, listProjectFileFolders } from '@/lib/projects/files/application' +import { createFileCopyAuthorizer } from '@/lib/workspace-files/application/copy-authorization' +import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const fixtures: { + users: string[] + organizationId: string + projectId: string + workspaces: string[] +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const readerId = generateId() + const organizationId = generateId() + const projectId = generateId() + const workspaces = [generateId(), generateId()] + fixtures.push({ users: [ownerId, readerId], organizationId, projectId, workspaces }) + await db.insert(user).values( + [ownerId, readerId].map((id) => ({ + id, + name: 'Project file fixture', + email: `${id}@files.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Project files', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, + { id: generateId(), organizationId, userId: readerId, role: 'member', createdAt: new Date() }, + ]) + await db.transaction(async (tx) => { + await tx + .insert(project) + .values({ id: projectId, organizationId, ownerId, name: 'Project files' }) + await tx.insert(workspace).values( + workspaces.map((id, index) => ({ + id, + organizationId, + ownerId, + billedAccountUserId: ownerId, + workspaceMode: 'organization' as const, + name: 'Environment', + projectId, + forkedFromWorkspaceId: index ? workspaces[0] : null, + })) + ) + }) + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: workspaces[0], + userId: readerId, + permissionType: 'read', + }) + return { + ownerId, + readerId, + organizationId, + projectId, + workspaces, + reader: createSessionPrincipal({ userId: readerId }), + } +} + +async function grant( + userId: string, + workspaceId: string, + permissionType: 'read' | 'write' | 'admin' +) { + await db + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType, + }) +} + +function delegated( + userId: string, + projectId: string, + workspaceId: string +): Extract { + return { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: userId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: projectId }, + invocation: { kind: 'workspace', workspaceId }, + } +} + +afterAll(async () => { + const reportPath = + process.env.PROJECT_FILE_AUTHORIZATION_REPORT_PATH ?? + resolve('test-results/project-file-authorization.json') + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) + for (const f of fixtures) { + const memberships = await db + .select({ projectId: workspace.projectId }) + .from(workspace) + .where(inArray(workspace.id, f.workspaces)) + const projectIds = [f.projectId, ...memberships.map((row) => row.projectId)] + await db.delete(workspaceFiles).where(inArray(workspaceFiles.projectId, projectIds)) + await db.delete(folder).where(inArray(folder.projectId, projectIds)) + await deleteWorkspaceFixture(db, inArray(workspace.id, f.workspaces)) + await db.delete(project).where(inArray(project.id, projectIds)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, f.users)) + } +}) + +describe('compound copy owner authority', () => { + function selection(f: Awaited>): Omit { + return { + source: { + owner: { entityType: 'project', entityId: f.projectId }, + fileIds: [generateId()], + folderIds: [], + }, + destination: { + owner: { entityType: 'workspace', entityId: f.workspaces[1] }, + folderId: null, + }, + } + } + + function copyPrincipal( + f: Awaited>, + input: Omit + ) { + return { + ...delegated(f.readerId, f.projectId, f.workspaces[0]), + audience: 'sim:files:copy', + scope: { kind: 'file_copy' as const, ...input }, + } + } + + check( + 'copy checks source read and destination write independently and rechecks after preparation', + async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const input = selection(f) + const authorize = await createFileCopyAuthorizer(f.reader, input) + expect(await db.transaction(authorize)).toMatchObject({ + source: { owner: input.source.owner, canWrite: false, ownerUserId: f.ownerId }, + destination: { owner: input.destination.owner, billedAccountUserId: f.ownerId }, + }) + const reverse = { + source: { ...input.source, owner: input.destination.owner }, + destination: { ...input.destination, owner: input.source.owner }, + } + const reverseAuthorize = await createFileCopyAuthorizer(f.reader, reverse) + await expect(db.transaction(reverseAuthorize)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + } + ) + + check( + 'copy refuses widened paired grants, entity grants, workspace keys and executors', + async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const input = selection(f) + const principal = copyPrincipal(f, input) + expect(await db.transaction(await createFileCopyAuthorizer(principal, input))).toMatchObject({ + source: { owner: input.source.owner }, + destination: { owner: input.destination.owner }, + }) + for (const changed of [ + { ...input, source: { ...input.source, fileIds: [generateId()] } }, + { ...input, destination: { ...input.destination, folderId: generateId() } }, + { ...input, source: { ...input.source, owner: input.destination.owner } }, + ]) { + await expect(createFileCopyAuthorizer(principal, changed)).rejects.toMatchObject({ + code: 'forbidden', + }) + } + const realtime: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId: f.readerId, + audience: 'sim:files:copy', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { + kind: 'entity', + entityType: 'project', + entityId: f.projectId, + fileId: input.source.fileIds[0], + }, + invocation: { kind: 'realtime', connectionId: 'copy-fixture' }, + } + for (const rejected of [ + realtime, + delegated(f.readerId, f.projectId, f.workspaces[0]), + createWorkspaceApiKeyPrincipal({ workspaceId: f.workspaces[1] }), + createExecutorPrincipal({ subjectUserId: f.readerId, workspaceId: f.workspaces[1] }), + ]) { + await expect(createFileCopyAuthorizer(rejected, input)).rejects.toMatchObject({ + code: 'forbidden', + }) + } + } + ) + + check('copy cannot escape the origin organization despite access to both owners', async () => { + const f = await fixture() + const other = await fixture() + for (const workspaceId of other.workspaces) await grant(f.ownerId, workspaceId, 'admin') + const input = { + ...selection(f), + destination: { + owner: { entityType: 'project' as const, entityId: other.projectId }, + folderId: null, + }, + } + const principal = { ...copyPrincipal(f, input), subjectUserId: f.ownerId } + const authorize = await createFileCopyAuthorizer(principal, input) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'not_found' }) + expect( + await db.transaction( + await createFileCopyAuthorizer(createSessionPrincipal({ userId: f.ownerId }), input) + ) + ).toMatchObject({ destination: { owner: input.destination.owner } }) + }) + + check('copy rechecks organization chat actor and mode at commit', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const input = selection(f) + const chatId = generateId() + await db.insert(copilotChats).values({ + id: chatId, + userId: f.readerId, + organizationId: f.organizationId, + workspaceId: null, + type: 'mothership', + config: { conversationMode: 'agent' }, + title: 'Copy files', + }) + const principal = { ...copyPrincipal(f, input), invocation: { kind: 'chat' as const, chatId } } + const authorize = await createFileCopyAuthorizer(principal, input) + expect(await db.transaction(authorize)).toMatchObject({ source: { owner: input.source.owner } }) + await db + .update(copilotChats) + .set({ config: { conversationMode: 'ask' } }) + .where(eq(copilotChats.id, chatId)) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(copilotChats) + .set({ config: { conversationMode: 'agent' }, userId: f.ownerId }) + .where(eq(copilotChats.id, chatId)) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'not_found' }) + }) + + check('copy applies personal-key availability and OAuth write scope to both owners', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + await db + .update(workspace) + .set({ allowPersonalApiKeys: true }) + .where(inArray(workspace.id, f.workspaces)) + const input = selection(f) + const key = createPersonalApiKeyPrincipal({ userId: f.readerId }) + expect(await db.transaction(await createFileCopyAuthorizer(key, input))).toMatchObject({ + destination: { owner: input.destination.owner }, + }) + await db + .update(workspace) + .set({ allowPersonalApiKeys: false }) + .where(eq(workspace.id, f.workspaces[0])) + await expect(db.transaction(await createFileCopyAuthorizer(key, input))).rejects.toMatchObject({ + code: 'forbidden', + }) + const oauth: OAuthAccessTokenPrincipal = { + kind: 'oauth_access_token', + userId: f.readerId, + tokenId: generateId(), + clientId: 'test-client', + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + } + await expect(createFileCopyAuthorizer(oauth, input)).rejects.toMatchObject({ + code: 'forbidden', + }) + }) + + check('copy blocks a suspended actor after preparation for workspace-only owners', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const original = selection(f) + const input = { + ...original, + source: { + ...original.source, + owner: { entityType: 'workspace' as const, entityId: f.workspaces[0] }, + }, + } + const authorize = await createFileCopyAuthorizer(f.reader, input) + expect(await db.transaction(authorize)).toMatchObject({ source: { owner: input.source.owner } }) + await db.update(user).set({ banned: true }).where(eq(user.id, f.readerId)) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + }) + + check( + 'copy locks opposite Project directions consistently in overlapping transactions', + async () => { + const f = await fixture() + const other = await fixture() + for (const workspaceId of other.workspaces) await grant(f.ownerId, workspaceId, 'admin') + const principal = createSessionPrincipal({ userId: f.ownerId }) + const input = { + ...selection(f), + destination: { + owner: { entityType: 'project' as const, entityId: other.projectId }, + folderId: null, + }, + } + const reverse = { + source: { ...input.source, owner: input.destination.owner }, + destination: { ...input.destination, owner: input.source.owner }, + } + const forward = await createFileCopyAuthorizer(principal, input) + const backward = await createFileCopyAuthorizer(principal, reverse) + const results = await Promise.all([db.transaction(forward), db.transaction(backward)]) + expect(results.map((result) => result.destination.owner.entityId)).toEqual([ + other.projectId, + f.projectId, + ]) + } + ) + + check( + 'copy does not upgrade shared membership locks across two Projects in one organization', + async () => { + const f = await fixture() + const extraWorkspaceId = generateId() + f.workspaces.push(extraWorkspaceId) + await insertWorkspaceFixture(db, { + id: extraWorkspaceId, + name: 'Separate Project', + organizationId: f.organizationId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + workspaceMode: 'organization', + }) + const [extra] = await db.select().from(workspace).where(eq(workspace.id, extraWorkspaceId)) + const first = selection(f) + const second = { + source: { + ...first.source, + owner: { entityType: 'project' as const, entityId: extra.projectId }, + }, + destination: { + owner: { entityType: 'workspace' as const, entityId: extraWorkspaceId }, + folderId: null, + }, + } + const principal = createSessionPrincipal({ userId: f.ownerId }) + const authorizeFirst = await createFileCopyAuthorizer(principal, first) + const authorizeSecond = await createFileCopyAuthorizer(principal, second) + const ready = createDeferred() + const release = createDeferred() + const organizationEdit = db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, f.organizationId) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + ready.resolve(connection.pid) + await release.promise + }) + const pid = await Promise.race([ + ready.promise, + organizationEdit.then(() => { + throw new Error('Organization edit exited early') + }), + ]) + const outcomes = Promise.allSettled([ + db.transaction(authorizeFirst), + db.transaction(authorizeSecond), + ]) + try { + let blocked = 0 + for (let attempt = 0; attempt < 100; attempt++) { + const [state] = await db.execute<{ count: number }>(sql` + SELECT count(*)::int AS count FROM pg_stat_activity + WHERE ${pid} = ANY(pg_blocking_pids(pid)) AND wait_event = 'advisory' + `) + blocked = state.count + if (blocked === 2) break + await sleep(10) + } + expect(blocked).toBe(2) + } finally { + release.resolve() + await organizationEdit + await outcomes + } + const results = await outcomes + expect(results.map((result) => result.status)).toEqual(['fulfilled', 'fulfilled']) + } + ) +}) + +describe('Mothership origin navigation and independent Project authority', () => { + async function target(f: Awaited>) { + const workspaceId = generateId() + f.workspaces.push(workspaceId) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Independent target', + organizationId: f.organizationId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + workspaceMode: 'organization', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Target Project missing') + await grant(f.readerId, workspaceId, 'admin') + return { workspaceId, projectId: binding.projectId } + } + + check( + 'origin A reads and writes B and authorizes A to B copy with independent live grants', + async () => { + const f = await fixture() + const b = await target(f) + const principal = delegated(f.readerId, b.projectId, f.workspaces[0]) + const result = await createProjectFileFolder.execute({ + principal, + input: { projectId: b.projectId, name: 'Target folder' }, + }) + expect( + ( + await listProjectFileFolders.execute({ principal, input: { projectId: b.projectId } }) + ).folders.map((row) => row.id) + ).toContain(result.folder.id) + const { folder: source } = await createProjectFileFolder.execute({ + principal: createSessionPrincipal({ userId: f.ownerId }), + input: { projectId: f.projectId, name: 'Copy me' }, + }) + const input = { + source: { + owner: { entityType: 'project' as const, entityId: f.projectId }, + fileIds: [], + folderIds: [source.id], + }, + destination: { + owner: { entityType: 'project' as const, entityId: b.projectId }, + folderId: null, + }, + } + const copy = { + ...principal, + audience: 'sim:files:copy', + scope: { kind: 'file_copy' as const, ...input }, + } + const copied = await copyFileItems.execute({ principal: copy, input }) + expect(copied.folders).toHaveLength(1) + expect( + ( + await listProjectFileFolders.execute({ principal, input: { projectId: b.projectId } }) + ).folders.map((row) => row.id) + ).toContain(copied.folders[0].id) + const authorize = await createFileCopyAuthorizer(copy, input) + expect(await db.transaction(authorize)).toMatchObject({ + source: { canWrite: false }, + destination: { canWrite: true }, + }) + await grant(f.readerId, b.workspaceId, 'read') + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + await db + .delete(permissions) + .where(and(eq(permissions.userId, f.readerId), eq(permissions.entityId, b.workspaceId))) + await expect( + listProjectFileFolders.execute({ principal, input: { projectId: b.projectId } }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'origin access and copilot capability are rechecked after preparation independently of B', + async () => { + for (const revoke of ['permission', 'copilot', 'files'] as const) { + const f = await fixture() + const b = await target(f) + const input = { + source: { + owner: { entityType: 'project' as const, entityId: b.projectId }, + fileIds: [generateId()], + folderIds: [], + }, + destination: { + owner: { entityType: 'project' as const, entityId: b.projectId }, + folderId: null, + }, + } + const principal = { + ...delegated(f.readerId, b.projectId, f.workspaces[0]), + audience: 'sim:files:copy', + scope: { kind: 'file_copy' as const, ...input }, + } + const authorize = await createFileCopyAuthorizer(principal, input) + await db.transaction(authorize) + if (revoke === 'permission') { + await db + .delete(permissions) + .where( + and(eq(permissions.userId, f.readerId), eq(permissions.entityId, f.workspaces[0])) + ) + } else { + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Revoked capability', + membershipMode: 'inherit', + config: revoke === 'copilot' ? { hideCopilot: true } : { hideFilesTab: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: revoke === 'copilot' ? f.workspaces[0] : b.workspaceId, + organizationId: f.organizationId, + }) + } + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + } + } + ) +}) diff --git a/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts b/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts new file mode 100644 index 00000000000..1ece5a545e8 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts @@ -0,0 +1,1472 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, readdir, readFile, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + folder, + idempotencyKey, + member, + organization, + outboxEvent, + permissionGroup, + permissionGroupWorkspace, + permissions, + subscription, + user, + workspace, + workspaceFileSecretProvenance, + workspaceFiles, + workspaceFileVersion, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { emailMailerMock } from '@sim/testing/mocks/email-mailer.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import JSZip from 'jszip' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) + +import { observeContentDelivery } from '@/lib/core/application/content-delivery' +import { + createProjectFile, + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { createProjectFileFolder } from '@/lib/projects/files/application/folders' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import * as storage from '@/lib/uploads/core/storage-service' +import { observeWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const localStorageRoot = mkdtempSync(join(tmpdir(), 'sim-project-content-')) +setUploadDirServer(localStorageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + vi.restoreAllMocks() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@content.invalid`, + name: 'File content fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Content', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Content environment', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + return { ...ids, principal: createSessionPrincipal({ userId: editorId }) } +} + +function createInput(projectId: string, content = 'Project architecture') { + return { + projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content, + encoding: 'utf-8' as const, + exactName: true, + } +} + +async function keys(projectId: string) { + return readdir(join(localStorageRoot, 'project', projectId)).catch(() => []) +} + +async function ledger(organizationId: string) { + const [row] = await db + .select({ bytes: organization.storageUsedBytes }) + .from(organization) + .where(eq(organization.id, organizationId)) + return row?.bytes +} + +async function rows(projectId: string) { + return db.select().from(workspaceFiles).where(eq(workspaceFiles.projectId, projectId)) +} + +describe('Project file content against PostgreSQL and the local object store', () => { + check( + 'folder tree copy hydrates destination paths once while preserving nested paths', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const f = await fixture() + const root = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Root' }, + }) + await db.insert(folder).values( + Array.from({ length: 20 }, (_, index) => ({ + id: generateId(), + projectId: f.projectId, + parentId: root.folder.id, + resourceType: 'file' as const, + name: `Child-${index}`, + userId: f.ownerId, + })) + ) + const originalDebug = db.$client.options.debug + const queries: string[] = [] + db.$client.options.debug = (_connection, query) => { + queries.push(query) + } + try { + const result = await copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'project', entityId: f.projectId }, + fileIds: [], + folderIds: [root.folder.id], + }, + destination: { + owner: { entityType: 'workspace', entityId: f.workspaceId }, + folderId: null, + }, + }, + }) + expect(result.folders).toHaveLength(21) + const copiedRoot = result.folders.find((item) => item.parentId === null) + if (!copiedRoot) throw new Error('Copied root missing') + expect(result.folders.filter((item) => item.parentId === copiedRoot.id)).toHaveLength(20) + for (const item of result.folders.filter((item) => item.id !== copiedRoot.id)) + expect(item.path).toBe(`${copiedRoot.path}/${item.name}`) + expect( + queries.filter((query) => + query.startsWith('select "id", "name", "parent_id" from "folder"') + ) + ).toHaveLength(0) + } finally { + db.$client.options.debug = originalDebug + } + } + ) + + check( + 'copy selection ignores unrelated folders when the owner exceeds the selection limit', + async () => { + const { snapshotFileCopyInTx } = await import('@/lib/workspace-files/copy') + const f = await fixture() + const selected = generateId() + await db.insert(folder).values( + Array.from({ length: 5_001 }, (_, index) => ({ + id: index === 0 ? selected : generateId(), + projectId: f.projectId, + resourceType: 'file' as const, + name: `folder-${index}`, + userId: f.ownerId, + })) + ) + const result = await db.transaction((tx) => + snapshotFileCopyInTx(tx, { + owner: { entityType: 'project', entityId: f.projectId }, + fileIds: [], + folderIds: [selected], + }) + ) + expect(result.folders.map((item) => item.id)).toEqual([selected]) + expect(result.files).toHaveLength(0) + } + ) + + check('copy retains committed bytes when COMMIT acknowledgement is lost', async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'retained after lost acknowledgement'), + }) + const original = db.transaction.bind(db) + vi.spyOn(db, 'transaction').mockImplementation(async (callback, config) => { + const result = await original(callback, config) + if (result && typeof result === 'object' && 'usage' in result && 'result' in result) + throw new Error('Lost COMMIT acknowledgement') + return result + }) + await expect( + copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'project', entityId: f.projectId }, + fileIds: [source.file.id], + folderIds: [], + }, + destination: { owner: { entityType: 'project', entityId: f.projectId }, folderId: null }, + }, + }) + ).rejects.toThrow('Lost COMMIT acknowledgement') + vi.restoreAllMocks() + const copied = (await rows(f.projectId)).find((file) => file.id !== source.file.id) + if (!copied) throw new Error('Copy did not commit') + expect(await readFile(join(localStorageRoot, copied.key), 'utf8')).toBe( + 'retained after lost acknowledgement' + ) + const cleanup = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::text LIKE ${`%${copied.key}%`}`) + expect(cleanup).toHaveLength(0) + }) + + check( + 'compound copy remaps selected page assets before staging and bills the actual rendered source bytes', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const image = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9uoAAAAASUVORK5CYII=', + 'base64' + ) + const asset = await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'diagram.png', + contentType: 'image/png', + content: image, + exactName: true, + }, + }) + const original = Buffer.from( + `---\ntitle: Architecture\n---\n\n![Diagram](sim:file/${asset.file.id})\n\n![Stored](/api/files/serve/${encodeURIComponent(asset.file.key)})` + ) + const source = await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'Architecture', + contentType: 'text/x-sim-page', + content: original, + exactName: true, + }, + }) + const before = await ledger(f.organizationId) + const copied = await copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'workspace', entityId: f.workspaceId }, + fileIds: [asset.file.id, source.file.id], + folderIds: [], + }, + destination: { owner: { entityType: 'project', entityId: f.projectId }, folderId: null }, + }, + }) + const copiedSource = copied.files.find((file) => file.name === 'Architecture') + const copiedAsset = copied.files.find((file) => file.name === 'diagram.png') + if (!copiedSource || !copiedAsset) throw new Error('Copy omitted selected source or asset') + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: copiedSource.id, maxBytes: 1_000_000 }, + }) + expect(rendered.buffer.toString()).toContain( + `data:image/png;base64,${image.toString('base64')}` + ) + const copiedBytes = await readFile(join(localStorageRoot, copiedSource.key)) + expect(copiedBytes.toString()).toContain(copiedAsset.id) + expect(copiedBytes.toString()).not.toContain(asset.file.id) + expect(await readFile(join(localStorageRoot, source.file.key))).toEqual(original) + expect(await readFile(join(localStorageRoot, copiedAsset.key))).toEqual(image) + expect(await ledger(f.organizationId)).toBe( + (before ?? 0) + copied.files.reduce((sum, file) => sum + file.size, 0) + ) + expect(copiedSource.size).toBe(copiedBytes.length) + } + ) + + check( + 'compound recursive copy preserves bytes and provenance under new owner and creator attribution', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { createWorkspaceFileFolderOperation } = await import( + '@/lib/workspace-files/application/folders' + ) + const f = await fixture() + const sourceOwner = { entityType: 'workspace' as const, entityId: f.workspaceId } + const destinationOwner = { entityType: 'project' as const, entityId: f.projectId } + const directory = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Source' }, + }) + const child = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Nested', parentId: directory.folder.id }, + }) + const secret = { + status: 'exact' as const, + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-fixture', + sourceUserId: f.ownerId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const source = await createWorkspaceFileFromBuffer.execute({ + principal: createSessionPrincipal({ userId: f.ownerId }), + input: { + workspaceId: f.workspaceId, + name: 'source.bin', + contentType: 'application/octet-stream', + content: Buffer.from([1, 255, 0]), + folderId: child.folder.id, + exactName: true, + secretProvenance: secret, + }, + }) + const copied = await copyFileItems.execute({ + principal: f.principal, + input: { + source: { owner: sourceOwner, fileIds: [], folderIds: [directory.folder.id] }, + destination: { owner: destinationOwner, folderId: null }, + }, + }) + expect(copied.files).toHaveLength(1) + expect(copied.folders).toHaveLength(2) + expect(copied.files[0]).toMatchObject({ + owner: destinationOwner, + uploadedBy: f.editorId, + folderPath: 'Source/Nested', + }) + expect(copied.files[0].id).not.toBe(source.file.id) + const read = await readProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: copied.files[0].id, + includeSecretProvenance: true, + }, + }) + expect(read.content).toEqual(Buffer.from([1, 255, 0])) + expect(read.secretProvenance).toEqual(secret) + expect(await ledger(f.organizationId)).toBe(6) + const sameOwner = await copyFileItems.execute({ + principal: f.principal, + input: { + source: { owner: destinationOwner, fileIds: [copied.files[0].id], folderIds: [] }, + destination: { owner: destinationOwner, folderId: null }, + }, + }) + expect(sameOwner.files[0].folderId).toBeNull() + expect(sameOwner.files[0].id).not.toBe(copied.files[0].id) + expect(await ledger(f.organizationId)).toBe(9) + } + ) + + check( + 'compound copy rejects source changes after staging without committing folders, files, or extra billing', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFile, createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { updateWorkspaceFileContent } = await import( + '@/lib/workspace-files/application/update-workspace-file-content' + ) + const { createWorkspaceFileFolderOperation } = await import( + '@/lib/workspace-files/application/folders' + ) + const f = await fixture() + const directory = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Source' }, + }) + const source = await createWorkspaceFile.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'notes.txt', + contentType: 'text/plain', + content: 'before', + encoding: 'utf-8', + folderId: directory.folder.id, + exactName: true, + }, + }) + await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'more.bin', + contentType: 'application/octet-stream', + content: Buffer.from([1]), + folderId: directory.folder.id, + exactName: true, + }, + }) + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const staged = await upload(options) + await updateWorkspaceFileContent.execute({ + principal: f.principal, + input: { + fileId: source.file.id, + assertedWorkspaceId: f.workspaceId, + content: 'after source change', + encoding: 'utf-8', + syncLiveDoc: false, + }, + }) + return staged + }) + await expect( + copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'workspace', entityId: f.workspaceId }, + fileIds: [], + folderIds: [directory.folder.id], + }, + destination: { + owner: { entityType: 'project', entityId: f.projectId }, + folderId: null, + }, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(await rows(f.projectId)).toEqual([]) + expect(await db.select().from(folder).where(eq(folder.projectId, f.projectId))).toEqual([]) + expect(await keys(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(Buffer.byteLength('after source change') + 1) + } + ) + + check( + 'compound copy quota failure atomically rolls back the whole destination tree and releases staged bytes', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFile } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { createWorkspaceFileFolderOperation } = await import( + '@/lib/workspace-files/application/folders' + ) + const f = await fixture() + const directory = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Source' }, + }) + const source = await createWorkspaceFile.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'notes.txt', + contentType: 'text/plain', + content: 'before', + encoding: 'utf-8', + folderId: directory.folder.id, + exactName: true, + }, + }) + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'workspace', entityId: f.workspaceId }, + fileIds: [], + folderIds: [directory.folder.id], + }, + destination: { + owner: { entityType: 'project', entityId: f.projectId }, + folderId: null, + }, + }, + }) + ).rejects.toMatchObject({ name: 'StorageLimitExceededError' }) + expect(await rows(f.projectId)).toEqual([]) + expect(await db.select().from(folder).where(eq(folder.projectId, f.projectId))).toEqual([]) + expect(await keys(f.projectId)).toEqual([]) + expect(await readFile(join(localStorageRoot, source.file.key), 'utf8')).toBe('before') + expect(await ledger(f.organizationId)).toBe(1024 ** 3) + } + ) +}) + +describe('Project archive download and Markdown snapshot export', () => { + check('snapshot export refuses an opaque embedded asset before fetching it', async () => { + const { exportProjectFileSnapshot } = await import('@/lib/projects/files/application/downloads') + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, '# source'), + }) + const image = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'image bytes'), + name: 'image.png', + contentType: 'image/png', + }, + }) + const principal: Principal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: f.editorId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + } + const read = vi.spyOn(storage, 'downloadFile') + await expect( + observeContentDelivery( + { + async admit(sources) { + expect(sources.every((item) => item.view === 'opaque')).toBe(true) + return sources.map((item) => item.id !== image.file.id) + }, + }, + () => + exportProjectFileSnapshot.execute({ + principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `![image](sim:file/${image.file.id}?project=${f.projectId})`, + secretProvenance: { status: 'exact', entries: [] }, + }, + }) + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(read).not.toHaveBeenCalled() + }) + + for (const phase of ['before-read', 'before-delivery'] as const) { + check(`Project archive opaque admission refuses ${phase}`, async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'private bytes'), + }) + const principal: Principal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: f.editorId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + } + const read = vi.spyOn(storage, 'downloadFile') + let admissions = 0 + await expect( + observeContentDelivery( + { + async admit(sources) { + admissions++ + expect(sources.every((source) => source.view === 'opaque')).toBe(true) + return sources.map(() => phase === 'before-delivery' && admissions === 1) + }, + }, + () => + downloadProjectFileItems.execute({ + principal, + input: { projectId: f.projectId, fileIds: [file.id], folderIds: [] }, + }) + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(read).toHaveBeenCalledTimes(phase === 'before-read' ? 0 : 1) + }) + } + + check( + 'Project recursive download retains nested paths, deduplicates selections, and excludes archived or foreign items', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + const { archiveProjectFileItems } = await import('@/lib/projects/files/application/lifecycle') + const f = await fixture() + const other = await fixture() + const root = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Architecture' }, + }) + const nested = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, parentId: root.folder.id, name: 'Diagrams' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'nested bytes'), folderId: nested.folder.id }, + }) + const archived = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'retired bytes'), folderId: root.folder.id }, + }) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [archived.file.id] }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: createInput(other.projectId, 'foreign bytes'), + }) + const foreignFolder = await createProjectFileFolder.execute({ + principal: other.principal, + input: { projectId: other.projectId, name: 'Architecture' }, + }) + const input = { + projectId: f.projectId, + fileIds: [source.file.id, source.file.id], + folderIds: [root.folder.id, nested.folder.id], + } + const result = await downloadProjectFileItems.execute({ principal: f.principal, input }) + const zip = await JSZip.loadAsync(result.buffer) + const entries = Object.values(zip.files).filter((entry) => !entry.dir) + expect(entries.map((entry) => entry.name)).toEqual(['Architecture/Diagrams/architecture.md']) + expect(await entries[0].async('string')).toBe('nested bytes') + expect(result.fileCount).toBe(1) + expect(result.secretProvenance).toEqual({ status: 'exact', entries: [] }) + for (const selection of [ + { ...input, fileIds: [foreign.file.id] }, + { ...input, folderIds: [foreignFolder.folder.id] }, + ]) { + await expect( + downloadProjectFileItems.execute({ principal: f.principal, input: selection }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + } + ) + + check( + 'Project recursive download enforces expanded file counts and actual aggregate bytes despite understated metadata', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const fileIds: string[] = [] + for (let index = 0; index < 3; index++) { + const result = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'x'), + name: `large-${index}.bin`, + contentType: 'application/octet-stream', + }, + }) + await truncate(join(localStorageRoot, result.file.key), 90 * 1024 * 1024) + fileIds.push(result.file.id) + } + await expect( + downloadProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds, folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + expect(await ledger(f.organizationId)).toBe(3) + const [template] = await rows(f.projectId) + if (!template) throw new Error('Download fixture metadata is missing') + const selected = Array.from({ length: 101 }, (_, index) => ({ + ...template, + id: generateId(), + key: `project/${f.projectId}/count-${index}`, + originalName: `count-${index}.bin`, + })) + await db.insert(workspaceFiles).values(selected) + await expect( + downloadProjectFileItems.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileIds: selected.map((file) => file.id), + folderIds: [], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + ) + + check( + 'Project recursive download rechecks source, descendant membership, and current access after object IO', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + for (const change of ['source', 'selection', 'access'] as const) { + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Selected' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'before IO'), folderId: directory.folder.id }, + }) + const download = storage.downloadFile + let changed = false + const interception = vi + .spyOn(storage, 'downloadFile') + .mockImplementation(async (options) => { + const buffer = await download(options) + if (options.key === source.file.key && !changed) { + changed = true + if (change === 'source') { + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'after IO', + encoding: 'utf-8', + }, + }) + } else if (change === 'selection') { + await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'new descendant'), + name: 'later.md', + folderId: directory.folder.id, + }, + }) + } else { + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + } + } + return buffer + }) + try { + await expect( + downloadProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [], folderIds: [directory.folder.id] }, + }) + ).rejects.toMatchObject({ code: change === 'access' ? 'not_found' : 'conflict' }) + } finally { + interception.mockRestore() + } + } + } + ) + + check( + 'Project recursive download aggregates bulk-download policy across accessible environments while retaining single-file reads', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Selected' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: directory.folder.id }, + }) + const secondId = generateId() + await insertWorkspaceFixture(db, { + id: secondId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + organizationId: f.organizationId, + workspaceMode: 'organization', + name: 'Restricted downloads', + forkedFromWorkspaceId: f.workspaceId, + }) + try { + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Restricted downloads', + membershipMode: 'inherit', + config: { disableBulkFileDownload: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: secondId, + organizationId: f.organizationId, + }) + const input = { projectId: f.projectId, fileIds: [], folderIds: [directory.folder.id] } + await downloadProjectFileItems.execute({ principal: f.principal, input }) + await db.insert(permissions).values({ + id: generateId(), + userId: f.editorId, + entityType: 'workspace', + entityId: secondId, + permissionType: 'read', + }) + await expect( + downloadProjectFileItems.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + const single = await downloadProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [source.file.id], folderIds: [] }, + }) + expect(single.fileCount).toBe(1) + } finally { + await deleteWorkspaceFixture(db, eq(workspace.id, secondId)) + } + } + ) + + check( + 'Project Markdown snapshot export packages only same-owner images, preserves source fidelity, and delivers joined provenance without persisting edits', + async () => { + const { exportProjectFileSnapshot } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const other = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + encryptedValue: 'asset-fixture', + sourceUserId: f.ownerId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const image = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'same-owner image bytes'), + name: 'diagram.png', + contentType: 'image/png', + secretProvenance: secret, + }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: { + ...createInput(other.projectId, 'foreign image bytes'), + name: 'secret.png', + contentType: 'image/png', + }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'durable draft'), + }) + const headBefore = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.id, source.file.id)) + const historyBefore = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, source.file.id)) + const visible = `---\ntitle: Architecture\n---\n\n# Unsaved snapshot\n\n![diagram](sim:file/${image.file.id}?project=${f.projectId})\n\n![foreign](/api/projects/${other.projectId}/files/${foreign.file.id}/content)\n\n\`sim:file/${image.file.id}\`\n` + let delivered: unknown + const exported = await observeWorkspaceFileDelivery( + async (source) => { + delivered = source + }, + () => + exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, content: visible }, + }) + ) + const zip = await JSZip.loadAsync(exported.buffer) + expect( + Object.values(zip.files) + .filter((entry) => !entry.dir) + .map((entry) => entry.name) + ).toEqual(['architecture.md', 'assets/diagram.png']) + expect(await zip.file('assets/diagram.png')?.async('string')).toBe('same-owner image bytes') + expect(await zip.file('architecture.md')?.async('string')).toBe( + visible.replace(`sim:file/${image.file.id}?project=${f.projectId}`, './assets/diagram.png') + ) + expect(exported.secretProvenance).toEqual(secret) + expect(delivered).toEqual(secret) + expect(await readFile(join(localStorageRoot, source.file.key), 'utf8')).toBe('durable draft') + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, source.file.id)) + expect(history).toEqual(historyBefore) + const headAfter = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.id, source.file.id)) + expect(headAfter).toEqual(headBefore) + + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'unknown source', + encoding: 'utf-8', + secretProvenance: { status: 'unknown' }, + }, + }) + const unknown = await exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, content: visible }, + }) + expect(unknown.secretProvenance.status).toBe('unknown') + } + ) + + check( + 'Project Markdown snapshot export never collapses admitted control-bearing names into ZIP traversal segments', + async () => { + const { exportProjectFileSnapshot } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const image = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'bundled asset'), + name: '.\r.', + contentType: 'image/png', + }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'durable draft'), name: '.\n.' }, + }) + const exported = await exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `![asset](sim:file/${image.file.id}?project=${f.projectId})`, + }, + }) + const zip = await JSZip.loadAsync(exported.buffer) + const entries = Object.values(zip.files).filter((entry) => !entry.dir) + expect(entries).toHaveLength(2) + for (const entry of entries) { + const rawPath = entry.unsafeOriginalName ?? entry.name + expect(rawPath).not.toMatch(/[\x00-\x1f\x7f]/) + expect( + rawPath.split('/').every((segment) => segment && segment !== '.' && segment !== '..') + ).toBe(true) + } + const asset = entries.find((entry) => entry.name.startsWith('assets/')) + expect(await asset?.async('string')).toBe('bundled asset') + } + ) + + check( + 'Project Markdown snapshot export refuses an asset revision changed after its actual bytes were read', + async () => { + const { exportProjectFileSnapshot } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const image = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'image before'), + name: 'diagram.png', + contentType: 'image/png', + }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const download = storage.downloadFile + let changed = false + vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { + const buffer = await download(options) + if (options.key === image.file.key && !changed) { + changed = true + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: image.file.id, + content: 'image after', + encoding: 'utf-8', + }, + }) + } + return buffer + }) + await expect( + exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `![diagram](sim:file/${image.file.id}?project=${f.projectId})`, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) +}) + +describe('Project ZIP extraction against PostgreSQL and local storage', () => { + async function archive( + f: Awaited>, + entries: Record, + provenance?: WorkspaceFileSecretProvenance + ) { + const zip = new JSZip() + for (const [path, content] of Object.entries(entries)) zip.file(path, content) + const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + return createProjectFile.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + name: 'bundle.zip', + contentType: 'application/zip', + content: buffer.toString('base64'), + encoding: 'base64', + secretProvenance: provenance, + }, + }) + } + + check('extraction retains committed bytes when COMMIT acknowledgement is lost', async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { 'retained.txt': 'retained extracted bytes' }) + const original = db.transaction.bind(db) + vi.spyOn(db, 'transaction').mockImplementation(async (callback, config) => { + const result = await original(callback, config) + if (result && typeof result === 'object' && 'usage' in result && 'result' in result) + throw new Error('Lost COMMIT acknowledgement') + return result + }) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toThrow('Lost COMMIT acknowledgement') + vi.restoreAllMocks() + const child = (await rows(f.projectId)).find((file) => file.id !== source.file.id) + if (!child) throw new Error('Extraction did not commit') + expect(await readFile(join(localStorageRoot, child.key), 'utf8')).toBe( + 'retained extracted bytes' + ) + const cleanup = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::text LIKE ${`%${child.key}%`}`) + expect(cleanup).toHaveLength(0) + }) + + check('extraction rejects an overlong directory before staging a member', async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { [`${'x'.repeat(256)}/entry.txt`]: 'content' }) + const upload = vi.spyOn(storage, 'uploadFile') + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(upload).not.toHaveBeenCalled() + expect(await folders(f.projectId)).toHaveLength(0) + expect(await rows(f.projectId)).toHaveLength(1) + }) + + async function folders(projectId: string) { + return db.select().from(folder).where(eq(folder.projectId, projectId)) + } + + check( + 'Project ZIP extraction atomically creates nested bytes beside the archive and bills the canonical payer', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const parent = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Resources' }, + }) + const source = await archive(f, { + 'docs/readme.txt': 'architecture', + 'images/icon.txt': 'diagram', + '__MACOSX/._readme.txt': 'noise', + }) + await db + .update(workspaceFiles) + .set({ folderId: parent.folder.id }) + .where(eq(workspaceFiles.id, source.file.id)) + await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'bundle', parentId: parent.folder.id }, + }) + const before = await ledger(f.organizationId) + const result = await extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + expect(result).toMatchObject({ extractedCount: 2, skippedCount: 1 }) + expect(result.folderName).not.toBe('bundle') + const tree = await folders(f.projectId) + const root = tree.find((row) => row.id === result.folderId) + expect(root).toMatchObject({ + parentId: parent.folder.id, + userId: f.editorId, + projectId: f.projectId, + workspaceId: null, + }) + const children = (await rows(f.projectId)).filter((row) => row.id !== source.file.id) + expect(children).toHaveLength(2) + for (const row of children) { + expect(row.userId).toBe(f.editorId) + expect(row.workspaceId).toBeNull() + const directory = tree.find((candidate) => candidate.id === row.folderId) + expect(directory?.parentId).toBe(result.folderId) + expect(await readFile(join(localStorageRoot, row.key), 'utf8')).toBe( + row.originalName === 'readme.txt' ? 'architecture' : 'diagram' + ) + } + expect(await ledger(f.organizationId)).toBe( + (before ?? 0) + Buffer.byteLength('architecturediagram') + ) + expect(await storage.downloadFile({ key: source.file.key, context: 'project' })).toHaveLength( + source.file.size + ) + } + ) + + check( + 'Project ZIP extraction conceals a foreign archive and refuses a read-only destination without publishing children', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const other = await fixture() + const source = await archive(f, { 'one.txt': 'one' }) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: (await archive(other, { 'secret.txt': 'secret' })).file.id, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + + for (const change of ['permission', 'source'] as const) { + check( + `Project ZIP extraction rejects ${change} changes after staging and leaves no partial tree`, + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { 'first.txt': 'first', 'nested/second.txt': 'second' }) + const upload = storage.uploadFile + let changed = false + vi.spyOn(storage, 'uploadFile').mockImplementation(async (options) => { + const result = await upload(options) + if (!changed) { + changed = true + if (change === 'permission') + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where( + and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId)) + ) + else + await db + .update(workspaceFiles) + .set({ originalName: 'renamed.zip', updatedAt: new Date() }) + .where(eq(workspaceFiles.id, source.file.id)) + } + return result + }) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ code: change === 'permission' ? 'forbidden' : 'conflict' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + } + + check( + 'Project ZIP extraction blocks concurrent work and fences a replaced lease before metadata commit', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { 'one.txt': 'one' }) + const uploaded = createDeferred() + const resume = createDeferred() + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const result = await upload(options) + uploaded.resolve() + await resume.promise + return result + }) + const args = { + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + } + const first = extractProjectFile.execute(args) + const firstResult = first.catch((error: unknown) => error) + await uploaded.promise + try { + await expect(extractProjectFile.execute(args)).rejects.toMatchObject({ code: 'conflict' }) + const replaced = await db + .update(idempotencyKey) + .set({ + result: { + status: 'in-progress', + claimToken: generateId(), + inProgressExpiresAt: Date.now() + 60_000, + }, + }) + .where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%${source.file.id}%`}`) + .returning({ key: idempotencyKey.key }) + expect(replaced).toHaveLength(1) + } finally { + resume.resolve() + } + expect(await firstResult).toMatchObject({ code: 'conflict' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + + check( + 'Project ZIP extraction quota failure rolls back every child and preserves the source archive', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { 'first.txt': 'first', 'nested/second.txt': 'second' }) + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ name: 'StorageLimitExceededError' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(1024 ** 3) + } + ) + + check( + 'Project ZIP extraction rejects over-deep member paths before any destination mutation', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const { MAX_FOLDER_PATH_SEGMENTS } = await import('@/lib/folders/paths') + const f = await fixture() + const source = await archive(f, { + [`${Array.from({ length: MAX_FOLDER_PATH_SEGMENTS + 1 }, () => 'deep').join('/')}/file.txt`]: + 'too deep', + }) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ reason: 'invalid' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + + check( + 'Project ZIP extraction preserves conservative provenance through decompression', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const classifications = [ + { status: 'exact' as const, entries: [] }, + { status: 'unknown' as const }, + { status: 'unrecorded' as const }, + { + status: 'exact' as const, + entries: [ + { + encryptedValue: 'archive-fixture', + sourceUserId: 'origin', + }, + ], + }, + ] + for (const classification of classifications) { + const f = await fixture() + const source = await archive(f, { 'data.txt': 'extracted' }, classification) + await extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + const [child] = (await rows(f.projectId)).filter((row) => row.id !== source.file.id) + if (!child) throw new Error('Extraction did not publish a child') + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, child.id)) + const expected = + classification.status === 'exact' + ? classification.entries.length + ? 'unknown' + : 'exact' + : classification.status + expect(provenance.status).toBe(expected) + expect(provenance.entries).toEqual([]) + expect(await readFile(join(localStorageRoot, child.key), 'utf8')).toBe('extracted') + } + } + ) +}) + +afterAll(async () => { + vi.restoreAllMocks() + for (const f of fixtures) { + await db.delete(idempotencyKey).where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%`}`) + await db + .delete(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(localStorageRoot, { recursive: true, force: true }) + const report = + process.env.PROJECT_FILE_CONTENT_REPORT_PATH ?? 'test-results/project-file-content.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + await db.$client.end() +}) diff --git a/apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts b/apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts new file mode 100644 index 00000000000..10298483fdf --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts @@ -0,0 +1,1003 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + fileSearchDispatchQueue, + folder, + idempotencyKey, + member, + organization, + outboxEvent, + permissions, + subscription, + user, + workspace, + workspaceFileSearchRevision, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { emailMailerMock } from '@sim/testing/mocks/email-mailer.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) + +import { observeContentDelivery } from '@/lib/core/application/content-delivery' +import * as sandboxTask from '@/lib/execution/sandbox/run-task' +import { readProjectFileArtifact } from '@/lib/projects/files/application/artifacts' +import { + createProjectFile, + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { + readProjectFileCsvPreview, + readProjectInlineFile, +} from '@/lib/projects/files/application/previews' +import { searchProjectFileContent } from '@/lib/projects/files/application/search' +import { revertProjectFileVersion } from '@/lib/projects/files/application/versions' +import { lockProjectShared } from '@/lib/projects/membership' +import * as storageCleanup from '@/lib/uploads/contexts/workspace/file-storage-cleanup-outbox' +import * as storage from '@/lib/uploads/core/storage-service' +import { compileFileDocument } from '@/lib/uploads/documents/compile' +import * as heic from '@/lib/uploads/server/heic' +import { observeWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { FILE_SEARCH_ARTIFACT_READY_LOCK_TIMEOUT_MS } from '@/lib/workspace-files/search/constants' +import * as searchDelivery from '@/lib/workspace-files/search/delivery' +import * as searchRepository from '@/lib/workspace-files/search/repository' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const localStorageRoot = mkdtempSync(join(tmpdir(), 'sim-project-content-')) +setUploadDirServer(localStorageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + vi.restoreAllMocks() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@content.invalid`, + name: 'File content fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Content', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Content environment', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + return { ...ids, principal: createSessionPrincipal({ userId: editorId }) } +} + +/** File creation marks a revision pending, so a requeue check starts from no revision at all. */ +async function clearSearchRevision(fileId: string) { + await db.delete(workspaceFileSearchRevision).where(eq(workspaceFileSearchRevision.fileId, fileId)) +} + +async function searchRevision(fileId: string) { + const [revision] = await db + .select() + .from(workspaceFileSearchRevision) + .where(eq(workspaceFileSearchRevision.fileId, fileId)) + return revision +} + +function createInput(projectId: string, content = 'Project architecture') { + return { + projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content, + encoding: 'utf-8' as const, + exactName: true, + } +} + +describe('Project file content against PostgreSQL and the local object store', () => { + for (const code of ['55P03', '25P04']) { + check(`Project search classifies delivery contention ${code} as retryable`, async () => { + const f = await fixture() + const delivery = vi + .spyOn(searchDelivery, 'loadFileSearchDelivery') + .mockRejectedValueOnce( + new Error('Failed query', { cause: Object.assign(new Error('lock timeout'), { code }) }) + ) + try { + await expect( + searchProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, query: 'needle', mode: 'exact', maxResults: 10 }, + }) + ).rejects.toMatchObject({ code: 'locked' }) + } finally { + delivery.mockRestore() + } + }) + } +}) + +describe('Project delivery admission', () => { + for (const kind of ['inline', 'csv', 'artifact'] as const) { + for (const refusal of ['before storage', 'after storage'] as const) { + check(`${kind} gates content ${refusal}`, async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'name,value\nrow,secret'), + name: 'rows.csv', + contentType: 'text/csv', + }, + }) + const issuedAt = new Date() + const principal: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: f.editorId, + delegationId: generateId(), + audience: 'sim:project-files', + issuedAt, + expiresAt: new Date(issuedAt.getTime() + 60_000), + invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + } + const download = vi.spyOn(storage, 'downloadFile') + const stream = vi.spyOn(storage, 'downloadFileStream') + let calls = 0 + const input = { projectId: f.projectId, fileId: created.file.id, key: created.file.key } + await expect( + observeContentDelivery( + { + async admit(sources) { + calls++ + expect( + sources.every((source) => source.view === (kind === 'csv' ? 'excerpt' : 'opaque')) + ).toBe(true) + return sources.map(() => refusal === 'after storage' && calls === 1) + }, + }, + () => + kind === 'inline' + ? readProjectInlineFile.execute({ + principal, + input: { projectId: f.projectId, referenceFileId: created.file.id }, + }) + : kind === 'csv' + ? readProjectFileCsvPreview.execute({ principal, input }) + : readProjectFileArtifact.execute({ + principal, + input: { ...input, maxBytes: 1024 }, + }) + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + if (refusal === 'before storage') { + expect(download).not.toHaveBeenCalled() + expect(stream).not.toHaveBeenCalled() + } else expect(calls).toBeGreaterThan(1) + }) + } + } + for (const kind of ['inline', 'csv', 'artifact'] as const) { + check(`${kind} awaits an asynchronous transport observer refusal`, async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'name,value\nrow,secret'), + name: 'rows.csv', + contentType: 'text/csv', + }, + }) + const error = new Error('Transport observer refused delivery') + const input = { projectId: f.projectId, fileId: created.file.id, key: created.file.key } + await expect( + observeWorkspaceFileDelivery( + async () => { + throw error + }, + () => + kind === 'inline' + ? readProjectInlineFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, referenceFileId: created.file.id }, + }) + : kind === 'csv' + ? readProjectFileCsvPreview.execute({ principal: f.principal, input }) + : readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, maxBytes: 1024 }, + }) + ) + ).rejects.toBe(error) + }) + } + + check('Project search withholds stale previews and removes moved-out results', async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const identity = { + fileId: created.file.id, + key: created.file.key, + contentUpdatedAt: new Date(), + } + const prepared: searchRepository.FileSearchResult = { + results: [ + { fileId: created.file.id, text: 'stale secret', lineNumber: 1 }, + { fileId: 'moved', text: 'out of scope', lineNumber: 1 }, + ], + count: 2, + truncated: false, + complete: true, + indexStatus: { + readyFiles: 0, + pendingFiles: 0, + failedFiles: 0, + skippedFiles: 0, + partialFiles: 0, + }, + sources: [ + { + owner: { entityType: 'project', entityId: f.projectId }, + identity, + buildId: 'old-build', + dependencies: [], + }, + ], + } + vi.spyOn(searchRepository, 'searchFileIndex').mockResolvedValue(prepared) + vi.spyOn(searchDelivery, 'loadFileSearchDelivery').mockResolvedValue({ + current: new Set(), + provenance: new Map(), + outOfScope: new Set(['moved']), + inputs: [], + }) + const result = await searchProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, query: 'secret', mode: 'exact', maxResults: 10 }, + }) + expect(result.count).toBe(1) + expect(result.results[0].text).toContain('Preview withheld') + expect(result.sources).toEqual([]) + expect(JSON.stringify(result)).not.toContain('stale secret') + }) +}) + +describe('Project outer transaction cleanup', () => { + for (const { operation, code } of [ + { operation: 'create', code: undefined }, + { operation: 'update', code: undefined }, + { operation: 'revert', code: undefined }, + { operation: 'preview', code: undefined }, + { operation: 'update', code: '40003' }, + { operation: 'update', code: 'CONNECTION_CLOSED' }, + ] as const) { + check( + `retains committed Project ${operation} bytes after acknowledgement loss (${code ?? 'uncoded'})`, + async () => { + const f = await fixture() + const sourceBytes = Buffer.alloc(16) + sourceBytes.writeUInt32BE(16, 0) + sourceBytes.write('ftypheic', 4, 'ascii') + const source = await createProjectFile.execute({ + principal: f.principal, + input: + operation === 'preview' + ? { + ...createInput(f.projectId), + name: 'preview.heic', + contentType: 'image/heic', + content: sourceBytes.toString('base64'), + encoding: 'base64', + } + : createInput(f.projectId, 'original'), + }) + if (operation === 'revert') + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'second', + encoding: 'utf-8', + }, + }) + vi.spyOn(heic, 'transcodeHeicToJpeg').mockResolvedValue(Buffer.alloc(128, 255)) + const upload = storage.uploadFile + const written: string[] = [] + vi.spyOn(storage, 'uploadFile').mockImplementation(async (args) => { + const result = await upload(args) + written.push(result.key) + return result + }) + const transaction = db.transaction.bind(db) + const primary = Object.assign( + new Error('Commit acknowledgement lost'), + code ? { code } : {} + ) + let lost = false + vi.spyOn(db, 'transaction').mockImplementation(async (callback, config) => { + const result = await transaction(callback, config) + if ( + !lost && + written.length > 0 && + isRecordLike(result) && + isRecordLike(result.result) && + 'file' in result.result + ) { + lost = true + throw primary + } + return result + }) + const input = { projectId: f.projectId, fileId: source.file.id } + const action = + operation === 'create' + ? createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'committed'), name: 'new.md' }, + }) + : operation === 'update' + ? updateProjectFileContent.execute({ + principal: f.principal, + input: { ...input, content: 'committed', encoding: 'utf-8' }, + }) + : operation === 'revert' + ? revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...input, version: 1, expectedCurrentVersion: 2 }, + }) + : readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, preview: true, maxBytes: 1024 }, + }) + await expect(action).rejects.toBe(primary) + expect(lost).toBe(true) + expect(written).toHaveLength(1) + for (const key of written) { + expect((await readFile(join(localStorageRoot, key))).length).toBeGreaterThan(0) + expect( + await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${key}`) + ).toEqual([]) + } + } + ) + } + + check('preserves size rejection and a concurrently published image derivative', async () => { + const f = await fixture() + const bytes = Buffer.alloc(16) + bytes.writeUInt32BE(16, 0) + bytes.write('ftypheic', 4, 'ascii') + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: 'preview.heic', + contentType: 'image/heic', + content: bytes.toString('base64'), + encoding: 'base64', + }, + }) + vi.spyOn(storageCleanup, 'enqueueWorkspaceFileStorageCleanups').mockRejectedValue( + new Error('Cleanup database unavailable') + ) + const input = { projectId: f.projectId, fileId: source.file.id, preview: true } + let winner: Buffer | undefined + vi.spyOn(heic, 'transcodeHeicToJpeg') + .mockResolvedValue(Buffer.alloc(128, 255)) + .mockImplementationOnce(async () => { + winner = ( + await readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, maxBytes: 1024 }, + }) + ).buffer + return Buffer.alloc(128, 255) + }) + await expect + .soft( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, preview: true, maxBytes: 64 }, + }) + ) + .rejects.toMatchObject({ name: 'PayloadSizeLimitError' }) + expect( + await readdir(join(localStorageRoot, 'project', f.projectId, 'image-derivative')) + ).toHaveLength(1) + const subsequent = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, maxBytes: 1024 }, + }) + expect(winner).toBeDefined() + expect(subsequent.buffer).toEqual(winner) + }) +}) + +describe('Project rendered artifacts against PostgreSQL and private storage', () => { + for (const failure of ['size rejection', 'revision change'] as const) { + check( + `a preview with ${failure} retains the shared derivative and preserves the original error`, + async () => { + const f = await fixture() + const sourceBytes = Buffer.alloc(16) + sourceBytes.writeUInt32BE(16, 0) + sourceBytes.write('ftypheic', 4, 'ascii') + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: 'preview.heic', + contentType: 'image/heic', + content: sourceBytes.toString('base64'), + encoding: 'base64', + }, + }) + vi.spyOn(heic, 'transcodeHeicToJpeg').mockImplementation(async () => { + if (failure === 'revision change') { + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'replacement source', + encoding: 'utf-8', + }, + }) + } + return Buffer.alloc(128, 255) + }) + const rendering = readProjectFileArtifact.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + preview: true, + maxBytes: failure === 'size rejection' ? 64 : 1024, + }, + }) + await expect + .soft(rendering) + .rejects.toMatchObject( + failure === 'size rejection' ? { name: 'PayloadSizeLimitError' } : { code: 'conflict' } + ) + expect + .soft(await readdir(join(localStorageRoot, 'project', f.projectId, 'image-derivative'))) + .toHaveLength(1) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + expect(current.content).toEqual( + failure === 'size rejection' ? sourceBytes : Buffer.from('replacement source') + ) + } + ) + } + + for (const failure of ['revoked read', 'uncertain pointer upload'] as const) { + check( + `a render with ${failure} preserves shared published objects without scheduling deletion`, + async () => { + const { readProjectFileArtifact } = await import( + '@/lib/projects/files/application/artifacts' + ) + const f = await fixture() + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'image'), + name: 'logo.png', + contentType: 'image/png', + }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, `getFileBase64('${asset.file.id}')`), + name: 'design.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue( + Buffer.from('PK\u0003\u0004document') + ) + const upload = storage.uploadFile + const writtenKeys: string[] = [] + vi.spyOn(storage, 'uploadFile').mockImplementation(async (options) => { + const result = await upload(options) + if (result.key.startsWith(`project/${f.projectId}/compiled/`)) { + writtenKeys.push(result.key) + if (result.key.endsWith('.published.json')) { + if (failure === 'uncertain pointer upload') throw new Error('Lost upload response') + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + } + } + return result + }) + vi.spyOn(storage, 'deleteFile').mockRejectedValue(new Error('Cleanup unavailable')) + const rendering = readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1_000_000 }, + }) + if (failure === 'revoked read') { + await expect(rendering).rejects.toMatchObject({ code: 'not_found' }) + } else { + await expect(rendering).rejects.toThrow('Lost upload response') + } + expect(writtenKeys).toHaveLength(2) + const events = await db + .select() + .from(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/compiled/%`}` + ) + expect(events).toEqual([]) + for (const key of writtenKeys) + expect((await readFile(join(localStorageRoot, key))).length).toBeGreaterThan(0) + expect(await readFile(join(localStorageRoot, source.file.key))).toEqual( + Buffer.from(`getFileBase64('${asset.file.id}')`) + ) + } + ) + } + + check( + 'artifact pages bind embedded asset provenance and separately gate opaque model delivery', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + encryptedValue: 'fixture-ciphertext', + sourceUserId: f.editorId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'private image bytes'), + name: 'diagram.png', + contentType: 'image/png', + secretProvenance: secret, + }, + }) + const source = `---\ntitle: Design\n---\n\n![Diagram](sim:file/${asset.file.id})` + const page = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, source), + name: 'design.html', + contentType: 'text/x-sim-page', + }, + }) + const input = { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 } + const rendered = await readProjectFileArtifact.execute({ principal: f.principal, input }) + expect(rendered.contentType).toBe('text/html') + expect(rendered.buffer.toString()).toContain( + `data:image/png;base64,${Buffer.from('private image bytes').toString('base64')}` + ) + expect(rendered.secretProvenance).toEqual(secret) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, forModel: true }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: asset.file.id, + content: 'public image bytes', + encoding: 'utf-8', + expectedUpdatedAt: asset.file.contentUpdatedAt, + }, + }) + const clean = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, forModel: true }, + }) + expect(clean.secretProvenance).toEqual({ status: 'exact', entries: [] }) + expect(clean.buffer.toString()).toContain( + Buffer.from('public image bytes').toString('base64') + ) + } + ) + + check( + 'a generated artifact read beside a concurrent Project reader completes and requeues search', + async () => { + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'const title = "concurrent"'), + name: 'deck.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + const artifact = Buffer.from('PK\u0003\u0004document') + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue(artifact) + let markHeld = () => {} + const held = new Promise((resolve) => { + markHeld = resolve + }) + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + const concurrentReader = db.transaction(async (tx) => { + await lockProjectShared(tx, f.projectId) + markHeld() + await released + }) + await held + await clearSearchRevision(source.file.id) + try { + const rendered = await observeWorkspaceFileDelivery( + async () => release(), + () => + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1_000_000 }, + }) + ) + expect(rendered.buffer).toEqual(artifact) + } finally { + release() + await concurrentReader + } + const revision = await searchRevision(source.file.id) + expect(revision?.status).toBe('pending') + expect(revision?.sourceContentUpdatedAt).toEqual(source.file.contentUpdatedAt) + } + ) + + check( + 'a contended search requeue gives up within its short bound without failing the read', + async () => { + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'const title = "contended"'), + name: 'deck.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + const artifact = Buffer.from('PK\u0003\u0004document') + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue(artifact) + let markHeld = () => {} + const held = new Promise((resolve) => { + markHeld = resolve + }) + let release = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + const concurrentReader = db.transaction(async (tx) => { + await lockProjectShared(tx, f.projectId) + markHeld() + await released + }) + await held + await clearSearchRevision(source.file.id) + try { + const started = performance.now() + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1_000_000 }, + }) + expect(rendered.buffer).toEqual(artifact) + expect(performance.now() - started).toBeLessThan( + FILE_SEARCH_ARTIFACT_READY_LOCK_TIMEOUT_MS + 2_000 + ) + } finally { + release() + await concurrentReader + } + expect(await searchRevision(source.file.id)).toBeUndefined() + } + ) + + check("a Project page embedding another owner's image is a missing input", async () => { + const f = await fixture() + for (const owner of [`workspace=${f.workspaceId}`, `project=${generateId()}`]) { + const page = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Design\n---\n\n![Diagram](sim:file/${generateId()}?${owner})` + ), + name: `${generateId()}.html`, + contentType: 'text/x-sim-page', + }, + }) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + }) + + check( + 'artifact source cannot import a foreign Project asset through a valid source-file capability', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const foreign = await fixture() + const asset = await createProjectFile.execute({ + principal: foreign.principal, + input: { + ...createInput(foreign.projectId, 'foreign bytes'), + name: 'private.png', + contentType: 'image/png', + }, + }) + const page = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Design\n---\n\n![Diagram](sim:file/${asset.file.id})` + ), + name: 'design.html', + contentType: 'text/x-sim-page', + }, + }) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check('a source renamed during rendering is refused', async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const { renameProjectFile } = await import('@/lib/projects/files/application/lifecycle') + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'const title = "test"'), + name: 'before.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + vi.spyOn(sandboxTask, 'runSandboxTask').mockImplementation(async () => { + await renameProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, name: 'after.docx' }, + }) + return Buffer.from('PK\u0003\u0004document') + }) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1_000_000 }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) + + check( + 'an asset changed during render cannot be delivered with stale provenance or source identity', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'before'), + name: 'diagram.png', + contentType: 'image/png', + }, + }) + const page = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Design\n---\n\n![Diagram](sim:file/${asset.file.id})` + ), + name: 'design.html', + contentType: 'text/x-sim-page', + }, + }) + const download = storage.downloadFile + let changed = false + vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { + const bytes = await download(options) + if (!changed && options.key === asset.file.key) { + changed = true + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: asset.file.id, + content: 'after', + encoding: 'utf-8', + expectedUpdatedAt: asset.file.contentUpdatedAt, + secretProvenance: { status: 'unknown' }, + }, + }) + } + return bytes + }) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'generated Office artifacts use the canonical owner cache without exposing generator source', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const a = await fixture() + const b = await fixture() + const source = 'const title = "architecture"' + const mime = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + const prepared = [] + for (const [f, marker] of [ + [a, 'project A'], + [b, 'project B'], + ] as const) { + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, source), + name: 'architecture.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + const artifact = Buffer.from(`PK\u0003\u0004${marker}`) + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue(artifact) + await compileFileDocument({ + owner: { entityType: 'project', entityId: f.projectId }, + source, + sourceMime: 'text/x-pptxgenjs', + fileName: 'architecture.pptx', + inputs: [], + maxBytes: 1_000_000, + }) + prepared.push({ f, created, artifact }) + } + for (const { f, created, artifact } of prepared) { + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + maxBytes: 1_000_000, + forModel: true, + }, + }) + expect(rendered.buffer).toEqual(artifact) + expect(rendered.contentType).toBe(mime) + expect(rendered.secretProvenance).toEqual({ status: 'exact', entries: [] }) + } + } + ) +}) + +afterAll(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + for (const f of fixtures) { + await db.delete(idempotencyKey).where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%`}`) + await db + .delete(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + await db + .delete(fileSearchDispatchQueue) + .where(eq(fileSearchDispatchQueue.entityId, f.projectId)) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(localStorageRoot, { recursive: true, force: true }) + const report = + process.env.PROJECT_FILE_RENDERED_CONTENT_REPORT_PATH ?? + 'test-results/project-file-rendered-content.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + await db.$client.end() +}) diff --git a/apps/sim/lib/projects/files/__integration__/content-sharing.integration.ts b/apps/sim/lib/projects/files/__integration__/content-sharing.integration.ts new file mode 100644 index 00000000000..4dc2fbaf745 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/content-sharing.integration.ts @@ -0,0 +1,947 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { db, runOutsideTransactionContext } from '@sim/db' +import { + folder, + idempotencyKey, + member, + organization, + outboxEvent, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + publicShare, + subscription, + user, + workspace, + workspaceFileSecretProvenance, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) + +import * as sandboxTask from '@/lib/execution/sandbox/run-task' +import { + createProjectFile, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import * as shareManager from '@/lib/public-shares/manager' +import * as storage from '@/lib/uploads/core/storage-service' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const localStorageRoot = mkdtempSync(join(tmpdir(), 'sim-project-content-')) +setUploadDirServer(localStorageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + vi.restoreAllMocks() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@content.invalid`, + name: 'File content fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Content', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Content environment', + }) + const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + return { ...ids, principal: createSessionPrincipal({ userId: editorId }) } +} + +function createInput(projectId: string, content = 'Project architecture') { + return { + projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content, + encoding: 'utf-8' as const, + exactName: true, + } +} + +describe('Public file shares against PostgreSQL and private storage', () => { + check( + 'public delivery rejects unknown and secret-bearing provenance before storage', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + for (const secretProvenance of [ + { status: 'unknown' as const }, + { + status: 'exact' as const, + entries: [{ encryptedValue: 'private-fixture', sourceUserId: f.editorId }], + }, + ]) { + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: `${generateId()}.md`, + secretProvenance, + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public fixture rejected') + const download = vi.spyOn(storage, 'downloadFile') + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(download).not.toHaveBeenCalled() + download.mockRestore() + } + } + ) + + check('non-SSO shares do not consult an unavailable session service', async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare } = await import('@/lib/public-shares/application') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const resolveSessionPrincipal = vi + .fn() + .mockRejectedValue(new Error('Session service unavailable')) + for (const authType of ['public', 'password', 'email'] as const) { + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType, + password: 'fixture-password', + allowedEmails: ['reader@shared.invalid'], + }, + }) + const result = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', resolveSessionPrincipal }, + }) + expect(result.authorized).toBe(authType === 'public') + } + expect(resolveSessionPrincipal).not.toHaveBeenCalled() + }) + + check( + 'public share configuration requires Project write and the canonical organization sharing policy', + async () => { + const { updateProjectFileShare, getProjectFileShare } = await import( + '@/lib/projects/files/application/shares' + ) + const f = await fixture() + const other = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const created = await updateProjectFileShare.execute({ principal: f.principal, input }) + expect(created.share.isActive).toBe(true) + const [stored] = await db + .select() + .from(publicShare) + .where(eq(publicShare.id, created.share.id)) + expect(stored).toMatchObject({ + entityType: 'project', + entityId: f.projectId, + workspaceId: null, + createdBy: f.editorId, + }) + await expect( + getProjectFileShare.execute({ + principal: other.principal, + input: { projectId: other.projectId, fileId: file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(eq(permissions.userId, f.editorId)) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await db + .update(permissions) + .set({ permissionType: 'admin' }) + .where(eq(permissions.userId, f.editorId)) + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + await db.insert(permissionGroup).values({ + id: generateId(), + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'No public sharing', + isDefault: true, + membershipMode: 'inherit', + config: { disablePublicFileSharing: true }, + }) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + const disabled = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, isActive: false }, + }) + expect(disabled.share).toMatchObject({ isActive: false, token: created.share.token }) + } + ) + + check( + 'public share policy includes each accessible active environment without a selected-environment bypass', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const secondId = generateId() + await insertWorkspaceFixture(db, { + id: secondId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + organizationId: f.organizationId, + workspaceMode: 'organization', + name: 'Restricted environment', + forkedFromWorkspaceId: f.workspaceId, + }) + try { + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Restricted sharing', + membershipMode: 'inherit', + config: { disablePublicFileSharing: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: secondId, + organizationId: f.organizationId, + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + await updateProjectFileShare.execute({ principal: f.principal, input }) + await db.insert(permissions).values({ + id: generateId(), + userId: f.editorId, + entityType: 'workspace', + entityId: secondId, + permissionType: 'read', + }) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + await db + .update(permissionGroup) + .set({ config: { allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + const allowed = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'a-valid-share-password' }, + }) + expect(allowed.share.authType).toBe('password') + const upsert = shareManager.upsertOwnedFileShare + const interleave = vi + .spyOn(shareManager, 'upsertOwnedFileShare') + .mockImplementationOnce(async (...args) => { + await runOutsideTransactionContext(() => + db + .update(publicShare) + .set({ authType: 'public', isActive: false }) + .where(eq(publicShare.id, allowed.share.id)) + ) + return upsert(...args) + }) + const reenabled = await updateProjectFileShare.execute({ principal: f.principal, input }) + expect(reenabled.share).toMatchObject({ authType: 'password', isActive: true }) + interleave.mockRestore() + } finally { + await deleteWorkspaceFixture(db, eq(workspace.id, secondId)) + } + } + ) + + check( + 'public share credentials preserve password cookies, verified email, and actual session SSO identity', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const { setDeploymentAuthCookie, deploymentAuthCookieName } = await import( + '@/lib/core/security/deployment' + ) + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'Shared architecture'), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'correct-password' }, + }) + const token = share.token + expect( + await authorizePublicFileShare({ token, credential: { method: 'GET' } }) + ).toMatchObject({ authorized: false, error: 'auth_required_password' }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'POST', password: 'wrong-password' }, + }) + ).toMatchObject({ authorized: false, error: 'Invalid password' }) + const accepted = await authorizePublicFileShare({ + token, + credential: { method: 'POST', password: 'correct-password' }, + }) + if (!accepted.authorized) throw new Error('Correct share password rejected') + expect((await readPublicFileShareContent({ grant: accepted.grant })).buffer.toString()).toBe( + 'Shared architecture' + ) + const [passwordShare] = await db + .select() + .from(publicShare) + .where(eq(publicShare.id, share.id)) + const response = NextResponse.json({}) + await setDeploymentAuthCookie({ response, cookiePrefix: 'file', resource: passwordShare }) + const cookies = Object.fromEntries( + response.cookies.getAll().map(({ name, value }) => [name, value]) + ) + expect( + await authorizePublicFileShare({ token, credential: { method: 'GET', cookies } }) + ).toMatchObject({ authorized: true }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'email', allowedEmails: ['viewer@shared.invalid'] }, + }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'POST', email: 'viewer@shared.invalid', cookies }, + }) + ).toMatchObject({ authorized: false, error: 'otp_required' }) + const [emailShare] = await db.select().from(publicShare).where(eq(publicShare.id, share.id)) + await setDeploymentAuthCookie({ + response, + cookiePrefix: 'file', + resource: emailShare, + verifiedEmail: 'viewer@shared.invalid', + }) + const emailCookie = response.cookies.get(deploymentAuthCookieName('file', share.id)) + if (!emailCookie) throw new Error('Email fixture token missing') + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', cookies: { [emailCookie.name]: emailCookie.value } }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: 'viewer@shared.invalid' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'sso', allowedEmails: [`${f.editorId}@content.invalid`] }, + }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', email: `${f.editorId}@content.invalid` }, + }) + ).toMatchObject({ authorized: false, error: 'auth_required_sso' }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', sessionPrincipal: f.principal }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: `${f.editorId}@content.invalid` }) + expect( + await authorizePublicFileShare({ + token, + credential: { + method: 'GET', + sessionPrincipal: createSessionPrincipal({ userId: f.ownerId }), + }, + }) + ).toMatchObject({ authorized: false }) + } + ) + + check( + 'public share password exchange binds its cookie to the current password and rejects wrong modes', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, authenticatePublicFileSharePassword } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const { share } = await updateProjectFileShare.execute({ principal: f.principal, input }) + await expect( + authenticatePublicFileSharePassword({ + token: share.token, + password: 'a-valid-share-password', + }) + ).rejects.toMatchObject({ code: 'validation' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'a-valid-share-password' }, + }) + expect( + await authenticatePublicFileSharePassword({ + token: share.token, + password: 'wrong-password', + }) + ).toMatchObject({ authorized: false, error: 'Invalid password' }) + const access = await authenticatePublicFileSharePassword({ + token: share.token, + password: 'a-valid-share-password', + }) + if (!access.authorized) throw new Error('Password exchange failed') + const cookies = { [access.cookie.name]: access.cookie.value } + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies }, + }) + ).toMatchObject({ authorized: true, authType: 'password' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, password: 'replacement-share-password' }, + }) + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies }, + }) + ).toMatchObject({ authorized: false, authType: 'password' }) + } + ) + + check( + 'public share OTP delivery and verification retain allowlists, code attempts, and current token policy', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, requestPublicFileShareOtp, verifyPublicFileShareOtp } = + await import('@/lib/public-shares/application') + const otpStore = await import('@/lib/core/security/otp') + const f = await fixture() + const email = `${generateId()}@shared.invalid` + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'email' as const, + allowedEmails: [email], + } + const { share } = await updateProjectFileShare.execute({ principal: f.principal, input }) + emailMailerMockFns.mockSendEmail.mockResolvedValue({ + success: true, + message: 'Local mail boundary accepted', + }) + await ( + await requestPublicFileShareOtp({ token: share.token, email: 'outsider@shared.invalid' }) + ).deliver() + expect(await otpStore.getOTP('file', share.id, 'outsider@shared.invalid')).toBeNull() + const failure = { authorized: false, status: 400, error: 'Invalid verification code' } + expect( + await verifyPublicFileShareOtp({ + token: share.token, + email: 'outsider@shared.invalid', + otp: '000000', + }) + ).toEqual(failure) + expect(await verifyPublicFileShareOtp({ token: share.token, email, otp: '000000' })).toEqual( + failure + ) + await (await requestPublicFileShareOtp({ token: share.token, email })).deliver() + const stored = await otpStore.getOTP('file', share.id, email) + if (!stored) throw new Error('Allowed email received no code') + const { otp } = otpStore.decodeOTPValue(stored) + expect( + await verifyPublicFileShareOtp({ token: share.token, email, otp: '000000' }) + ).toMatchObject({ authorized: false, status: 400 }) + const accepted = await verifyPublicFileShareOtp({ token: share.token, email, otp }) + if (!accepted.authorized) throw new Error('Valid verification code rejected') + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies: { [accepted.cookie.name]: accepted.cookie.value } }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: email }) + expect(await verifyPublicFileShareOtp({ token: share.token, email, otp })).toMatchObject({ + authorized: false, + status: 400, + }) + await otpStore.storeOTP('file', share.id, email, '123456') + const get = otpStore.getOTP + const spy = vi.spyOn(otpStore, 'getOTP').mockImplementation(async (...args) => { + const result = await get(...args) + await db + .update(publicShare) + .set({ allowedEmails: ['replacement@shared.invalid'] }) + .where(eq(publicShare.id, share.id)) + return result + }) + await expect( + verifyPublicFileShareOtp({ token: share.token, email, otp: '123456' }) + ).rejects.toMatchObject({ code: 'not_found' }) + spy.mockRestore() + await otpStore.deleteOTP('file', share.id, email) + } + ) + + check( + 'public share SSO eligibility never grants bytes and follows current active owner policy', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, getPublicFileShareSsoEligibility } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const email = `${f.editorId}@content.invalid` + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'sso', + allowedEmails: [email], + }, + }) + expect(await getPublicFileShareSsoEligibility({ token: share.token, email })).toEqual({ + allowed: true, + eligible: true, + }) + expect( + await getPublicFileShareSsoEligibility({ + token: share.token, + email: 'outsider@shared.invalid', + }) + ).toEqual({ allowed: true, eligible: false }) + expect( + await authorizePublicFileShare({ token: share.token, credential: { method: 'GET', email } }) + ).toMatchObject({ authorized: false, authType: 'sso' }) + await db.update(publicShare).set({ isActive: false }).where(eq(publicShare.id, share.id)) + await expect( + getPublicFileShareSsoEligibility({ token: share.token, email }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'public share Office artifacts require the current owner-qualified dependency cache without compilation', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'first input'), name: 'input.txt' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, `getFileBase64('${asset.file.id}')`), + name: 'report.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public artifact fixture rejected') + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'conflict', + }) + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue( + Buffer.from('PK\u0003\u0004artifact') + ) + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1024 }, + }) + expect((await readPublicFileShareContent({ grant: access.grant })).buffer).toEqual( + rendered.buffer + ) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: asset.file.id, + content: 'changed input', + encoding: 'utf-8', + }, + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'conflict', + }) + } + ) + + check( + 'public share revocation and password changes during object reads suppress downloaded bytes', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const { encryptSecret } = await import('@/lib/core/security/encryption') + for (const change of ['disabled', 'password', 'archived', 'provenance'] as const) { + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'password', + password: 'initial-password', + }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'POST', password: 'initial-password' }, + }) + if (!access.authorized) throw new Error('Initial credential rejected') + const download = storage.downloadFile + const spy = vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { + const result = await download(options) + if (options.key === file.key) { + if (change === 'disabled') + await db + .update(publicShare) + .set({ isActive: false }) + .where(eq(publicShare.id, share.id)) + if (change === 'password') + await db + .update(publicShare) + .set({ password: (await encryptSecret('replacement-password')).encrypted }) + .where(eq(publicShare.id, share.id)) + if (change === 'provenance') + await db + .update(workspaceFileSecretProvenance) + .set({ status: 'unknown' }) + .where(eq(workspaceFileSecretProvenance.fileId, file.id)) + if (change === 'archived') + await db + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(eq(workspaceFiles.id, file.id)) + } + return result + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: change === 'provenance' ? 'forbidden' : 'not_found', + }) + spy.mockRestore() + } + } + ) + + check( + 'public share grants cannot be cloned or reused after Project archive and preserve workspace links', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShare, readPublicFileShareContent } = + await import('@/lib/public-shares/application') + const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace/file-manager') + const { upsertFileShare } = await import('@/lib/public-shares/manager') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public share rejected') + expect(await readPublicFileShare({ grant: access.grant })).toMatchObject({ + file: { id: file.id, originalName: 'architecture.md' }, + }) + await expect( + readPublicFileShareContent({ grant: { ...access.grant } }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db.transaction(async (tx) => { + await tx + .update(workspace) + .set({ archivedAt: new Date() }) + .where(eq(workspace.id, f.workspaceId)) + await tx.update(project).set({ archivedAt: new Date() }).where(eq(project.id, f.projectId)) + }) + await expect(readPublicFileShare({ grant: access.grant })).rejects.toMatchObject({ + code: 'not_found', + }) + await db.transaction(async (tx) => { + await tx.update(project).set({ archivedAt: null }).where(eq(project.id, f.projectId)) + await tx.update(workspace).set({ archivedAt: null }).where(eq(workspace.id, f.workspaceId)) + }) + const legacy = await uploadWorkspaceFile( + f.workspaceId, + f.ownerId, + Buffer.from('Workspace link'), + 'legacy.txt', + 'text/plain' + ) + const legacyShare = await upsertFileShare({ + workspaceId: f.workspaceId, + fileId: legacy.id, + userId: f.ownerId, + isActive: true, + }) + const legacyAccess = await authorizePublicFileShare({ + token: legacyShare.token, + credential: { method: 'GET' }, + }) + if (!legacyAccess.authorized) throw new Error('Existing workspace share rejected') + expect( + (await readPublicFileShareContent({ grant: legacyAccess.grant })).buffer.toString() + ).toBe('Workspace link') + } + ) + + check( + 'public share page assets remain confined to explicit references in the canonical owner', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent, readPublicFileShareInline } = + await import('@/lib/public-shares/application') + const f = await fixture() + const other = await fixture() + const image = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9uoAAAAASUVORK5CYII=', + 'base64' + ) + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, image.toString('base64')), + encoding: 'base64', + name: 'diagram.png', + contentType: 'image/png', + }, + }) + const sibling = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'Private sibling'), name: 'private.txt' }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: createInput(other.projectId), + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Architecture\n---\n\n![Diagram](sim:file/${asset.file.id})` + ), + name: 'Architecture', + contentType: 'text/x-sim-page', + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public page rejected') + const rendered = await readPublicFileShareContent({ grant: access.grant }) + expect(rendered.contentType).toBe('text/html') + expect(rendered.buffer.toString()).toContain( + `data:image/png;base64,${image.toString('base64')}` + ) + expect( + (await readPublicFileShareInline({ grant: access.grant, fileId: asset.file.id })).buffer + ).toEqual(image) + for (const fileId of [sibling.file.id, foreign.file.id]) + await expect( + readPublicFileShareInline({ grant: access.grant, fileId }) + ).rejects.toMatchObject({ code: 'not_found' }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `---\ntitle: Architecture\n---\n\n![Foreign](sim:file/${foreign.file.id})`, + encoding: 'utf-8', + }, + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'not_found', + }) + } + ) +}) + +afterAll(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + for (const f of fixtures) { + await db.delete(idempotencyKey).where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%`}`) + await db + .delete(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + await db + .delete(publicShare) + .where(and(eq(publicShare.entityType, 'project'), eq(publicShare.entityId, f.projectId))) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(localStorageRoot, { recursive: true, force: true }) + const report = + process.env.PROJECT_FILE_CONTENT_REPORT_PATH ?? 'test-results/project-file-content.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + await db.$client.end() +}) diff --git a/apps/sim/lib/projects/files/api/content-presenter.ts b/apps/sim/lib/projects/files/api/content-presenter.ts index 31ce36ac8d3..6edf243a029 100644 --- a/apps/sim/lib/projects/files/api/content-presenter.ts +++ b/apps/sim/lib/projects/files/api/content-presenter.ts @@ -2,17 +2,17 @@ import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' /** Presents authorized bytes without caching the mutable file URL. */ -export function presentProjectFileContent({ - file, - stream, - contentLength, -}: { - file: OwnedFileRecord - stream: ReadableStream - contentLength: number -}) { +export function presentProjectFileContent( + result: { file: OwnedFileRecord } & ( + | { stream: ReadableStream; contentLength: number } + | { content: Buffer } + ) +) { + const { file } = result + const body = 'content' in result ? result.content : result.stream + const contentLength = 'content' in result ? result.content.byteLength : result.contentLength return presentFileDelivery({ - body: stream, + body, filename: file.name, contentType: file.type, contentLength, diff --git a/apps/sim/lib/projects/files/api/download-presenter.ts b/apps/sim/lib/projects/files/api/download-presenter.ts new file mode 100644 index 00000000000..b4cddc60067 --- /dev/null +++ b/apps/sim/lib/projects/files/api/download-presenter.ts @@ -0,0 +1,17 @@ +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' + +/** Serves the authorized archive through a buffer view, without cloning its bounded payload. */ +export function presentProjectFileDownload(result: { + buffer: Buffer + fileName: string + contentType: string +}) { + return presentFileDelivery({ + body: result.buffer, + filename: result.fileName, + contentType: result.contentType, + contentLength: result.buffer.length, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }) +} diff --git a/apps/sim/lib/projects/files/application/artifacts.ts b/apps/sim/lib/projects/files/application/artifacts.ts new file mode 100644 index 00000000000..b356ebda15e --- /dev/null +++ b/apps/sim/lib/projects/files/application/artifacts.ts @@ -0,0 +1,471 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceFileRow } from '@sim/db/schema' +import { workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, asc, inArray, isNull } from 'drizzle-orm' +import { requireAdmittedContent } from '@/lib/core/application/content-delivery' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + buildWorkspaceFileFolderPathMap, + listFileFolders, + mapFileRecord, + type OwnedFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { + collectReferencedFileIds, + compileFileDocument, + getDocumentSourceLanguage, + getE2BDocFormat, + isCompiledDocumentBuffer, + resolveDocumentRender, +} from '@/lib/uploads/documents' +import { fileDocumentInputIdentity } from '@/lib/uploads/documents/input-identity' +import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' +import { getWorkspaceFileSize, MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { + isSimPageSource, + ProjectPageForeignEmbedError, + SIM_PAGE_CONTENT_TYPE, +} from '@/lib/workspace-files/page-compile' +import { renderSimPageDocument } from '@/lib/workspace-files/page-document' +import { + collectSimPageFileReferences, + inlineSimPageImages, + MAX_INLINE_IMAGE_BYTES, + MAX_INLINE_IMAGE_REFERENCES, + MAX_INLINE_TOTAL_BYTES, +} from '@/lib/workspace-files/page-document.server' +import { createFileReadReceipt, type FileReadReceipt } from '@/lib/workspace-files/read-receipt' +import { + type FileSearchArtifactCompletion, + requeueFileSearchForRenderedArtifact, +} from '@/lib/workspace-files/search/artifact-ready' + +const logger = createLogger('ProjectFileArtifacts') + +/** Generated-document input limits; `compileFileDocument` enforces the same values on the bytes. */ +const MAX_DOCUMENT_INPUTS = 500 +const MAX_DOCUMENT_INPUT_BYTES = 25 * 1024 * 1024 +const MAX_DOCUMENT_INPUT_TOTAL_BYTES = 50 * 1024 * 1024 + +type ProjectOwner = { entityType: 'project'; entityId: string } +interface ArtifactInput extends ProjectFileTarget { + fileId: string + maxBytes: number + forModel?: boolean + preview?: boolean +} +interface SourceSnapshot { + file: WorkspaceFileRow + owner: ProjectOwner + organizationId: string | null + secretProvenance: WorkspaceFileSecretProvenance | undefined +} +interface ArtifactPlan { + /** The decoded source, only for a page or generated document. */ + source?: string + pageHtml?: string + generatedDocument: boolean + dependencyIds: string[] +} +interface PreparedArtifact { + artifactKey?: string + source: WorkspaceFileRow + dependencies: WorkspaceFileRow[] + buffer: Buffer + contentType: string + dependsOnReferencedFiles: boolean +} +interface ArtifactResult { + file: OwnedFileRecord + buffer: Buffer + contentType: string + dependsOnReferencedFiles: boolean + secretProvenance: WorkspaceFileSecretProvenance + receipt: FileReadReceipt +} + +/** + * Search requeues carried from the commit to the post-commit phase. The requeue holds the Project + * exclusively, so it never runs inside the read's transaction, which holds the Project shared. + */ +const renderedSearchArtifacts = new WeakMap() + +function requireSameRevision(current: WorkspaceFileRow | undefined, expected: WorkspaceFileRow) { + if ( + !current || + current.key !== expected.key || + current.originalName !== expected.originalName || + current.contentType !== expected.contentType || + current.contentUpdatedAt.getTime() !== expected.contentUpdatedAt.getTime() + ) { + throw new OrchestrationError('conflict', 'A document source or input changed during rendering') + } +} + +async function loadDependencies(tx: DbTransaction, owner: ProjectOwner, ids: string[]) { + if (ids.length === 0) return [] + const rows = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + inArray(workspaceFiles.id, ids), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .for('share') + if (rows.length !== ids.length) + throw new OrchestrationError('not_found', 'Document input not found') + return rows +} + +/** Classifies the source bytes and names the same-Project inputs its render reads. */ +async function planArtifact( + file: WorkspaceFileRow, + content: Buffer, + owner: ProjectOwner +): Promise { + const pageCandidate = + file.contentType === SIM_PAGE_CONTENT_TYPE || file.originalName.toLowerCase().endsWith('.html') + const format = await getE2BDocFormat(file.originalName) + if (!pageCandidate && format === null) return { generatedDocument: false, dependencyIds: [] } + const source = content.toString('utf-8') + const page = pageCandidate && isSimPageSource(source) + let pageHtml: string | undefined + if (page) { + try { + pageHtml = renderSimPageDocument(source, { projectId: owner.entityId }) + } catch (error) { + if (error instanceof ProjectPageForeignEmbedError) + throw new OrchestrationError('not_found', 'Document input not found') + throw error + } + } + const generatedDocument = + !page && format !== null && !isCompiledDocumentBuffer(file.originalName, content) + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + if (references.some((reference) => reference.projectId && reference.projectId !== owner.entityId)) + throw new OrchestrationError('not_found', 'Document input not found') + const dependencyIds = [ + ...new Set( + pageHtml + ? references.map((reference) => reference.fileId) + : generatedDocument && format + ? collectReferencedFileIds( + source, + isDocSandboxEnabled + ? getDocumentSourceLanguage(source, format, file.contentType) + : 'javascript' + ) + : [] + ), + ] + if (dependencyIds.length > (pageHtml ? MAX_INLINE_IMAGE_REFERENCES : MAX_DOCUMENT_INPUTS)) + throw new OrchestrationError('payload_too_large', 'Document exceeds the referenced input limit') + return { source, pageHtml, generatedDocument, dependencyIds } +} + +/** + * Reads inputs with bounded fan-out. Declared sizes are checked before any read, and each read is + * capped at its declared size plus the unclaimed budget, so accurate rows never exceed the total. + */ +async function downloadDependencies( + dependencies: readonly WorkspaceFileRow[], + limits: { perFile: number; total: number }, + signal?: AbortSignal +) { + const declared = dependencies.map((dependency) => getWorkspaceFileSize(dependency)) + for (const size of declared) assertKnownSizeWithinLimit(size, limits.perFile, 'document input') + const declaredTotal = declared.reduce((sum, size) => sum + size, 0) + assertKnownSizeWithinLimit(declaredTotal, limits.total, 'document inputs') + const unclaimed = limits.total - declaredTotal + const failed = new AbortController() + const abort = signal ? AbortSignal.any([signal, failed.signal]) : failed.signal + let received = 0 + try { + return await mapWithConcurrency( + dependencies, + MATERIALIZE_CONCURRENCY, + async (dependency, index) => { + abort.throwIfAborted() + const content = await downloadFile({ + key: dependency.key, + context: 'project', + maxBytes: Math.min(limits.perFile, declared[index] + unclaimed), + signal: abort, + }) + received += content.length + assertKnownSizeWithinLimit(received, limits.total, 'document inputs') + return { fileId: dependency.id, contentType: dependency.contentType, content } + } + ) + } catch (error) { + failed.abort(error) + throw error + } +} + +const snapshotSource = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readArtifact, + ArtifactInput, + SourceSnapshot +>({ + operation: projectFileOperations.readArtifact, + async execute({ + context, + tx, + }: { + context: ProjectFileAuthorizationContext + tx: DbTransaction + input: ArtifactInput + }) { + const file = context.file + if (!file) throw new OrchestrationError('not_found', 'File not found') + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file]) + return { + file, + owner: context.owner, + organizationId: context.organizationId, + secretProvenance: evidence.get(file.id), + } + }, + afterSuccess: ({ principal, result }) => + requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: result.secretProvenance ?? { status: 'unknown' }, + view: 'opaque', + }), +}) + +/** Locks the named inputs against the unchanged source and admits their merged provenance before any input read. */ +const snapshotDependencies = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readArtifact, + ArtifactInput & { source: WorkspaceFileRow; dependencyIds: string[] }, + { dependencies: WorkspaceFileRow[]; secretProvenance: WorkspaceFileSecretProvenance } +>({ + operation: projectFileOperations.readArtifact, + async execute({ input, context, tx }) { + requireSameRevision(context.file, input.source) + const dependencies = await loadDependencies(tx, context.owner, input.dependencyIds) + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [ + input.source, + ...dependencies, + ]) + return { + dependencies, + secretProvenance: mergeWorkspaceFileSecretProvenance(...evidence.values()), + } + }, + afterSuccess: ({ principal, input, result }) => + requireAdmittedContent(principal, { + kind: 'workspace-file', + id: input.source.id, + provenance: result.secretProvenance, + view: 'opaque', + }), +}) + +async function renderArtifact( + principal: Principal, + input: ArtifactInput, + snapshot: SourceSnapshot, + signal?: AbortSignal +): Promise { + const { file, owner } = snapshot + const artifact = await resolveDocumentRender( + file.originalName, + { maxBytes: input.maxBytes, signal }, + async (): Promise => { + const content = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: input.maxBytes, + signal, + }) + const plan = await planArtifact(file, content, owner) + const dependencies = + plan.dependencyIds.length === 0 + ? [] + : ( + await snapshotDependencies.execute({ + principal, + input: { ...input, source: file, dependencyIds: plan.dependencyIds }, + }) + ).dependencies + const inputs = await downloadDependencies( + dependencies, + plan.pageHtml + ? { perFile: MAX_INLINE_IMAGE_BYTES, total: MAX_INLINE_TOTAL_BYTES } + : { perFile: MAX_DOCUMENT_INPUT_BYTES, total: MAX_DOCUMENT_INPUT_TOTAL_BYTES }, + signal + ) + if (plan.pageHtml) { + const byId = new Map(inputs.map((input) => [input.fileId, input])) + const page = await inlineSimPageImages( + plan.pageHtml, + async ({ fileId }) => { + const asset = byId.get(fileId) + if (!asset) throw new OrchestrationError('not_found', 'Document input not found') + return { bytes: asset.content, contentType: asset.contentType, identity: fileId } + }, + { strict: true } + ) + const buffer = Buffer.from(page.html, 'utf-8') + assertKnownSizeWithinLimit(buffer.length, input.maxBytes, 'rendered page') + return { + source: file, + dependencies, + buffer, + contentType: 'text/html', + dependsOnReferencedFiles: inputs.length > 0, + } + } + if (plan.generatedDocument && plan.source !== undefined) { + const compiled = await compileFileDocument({ + owner, + sourceMime: file.contentType, + source: plan.source, + fileName: file.originalName, + inputs, + inputIdentity: fileDocumentInputIdentity(owner, dependencies), + maxBytes: input.maxBytes, + signal, + }) + return { source: file, dependencies, ...compiled } + } + return { + source: file, + dependencies, + buffer: content, + contentType: file.contentType, + dependsOnReferencedFiles: false, + } + } + ) + const derivative = input.preview + ? await resolveServableImageBytes(artifact.buffer, file.key, { owner }) + : null + if (derivative) + assertKnownSizeWithinLimit(derivative.buffer.length, input.maxBytes, 'image preview') + return { ...artifact, ...derivative } +} + +/** Indexing is best effort: a contended or failed requeue never fails a delivered read. */ +async function requeueRenderedSearchArtifact(completion: FileSearchArtifactCompletion) { + try { + await requeueFileSearchForRenderedArtifact(completion) + } catch (error) { + logger.warn('Could not requeue file search for a rendered artifact', { + fileId: completion.file.fileId, + error: getErrorMessage(error), + }) + } +} + +/** Renders only source-owned dependencies, then rechecks the source, every input, and current authority. */ +export const readProjectFileArtifact = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readArtifact, + ArtifactInput, + ArtifactResult, + PreparedArtifact +>({ + operation: projectFileOperations.readArtifact, + async prepareFromSnapshot({ principal, input, request }) { + if ( + !Number.isSafeInteger(input.maxBytes) || + input.maxBytes <= 0 || + input.maxBytes > MAX_BUFFERED_TRANSFER_BYTES + ) { + throw new OrchestrationError('validation', 'Invalid document byte limit') + } + const snapshot = await snapshotSource.execute({ principal, input }) + return runWithOutboundOrganization(snapshot.organizationId, () => + renderArtifact(principal, input, snapshot, request?.signal) + ) + }, + async execute({ input, context, tx, prepared }) { + if (!prepared) throw new Error('Prepared document is unavailable') + requireSameRevision(context.file, prepared.source) + const dependencies = await loadDependencies( + tx, + context.owner, + prepared.dependencies.map((file) => file.id) + ) + const currentById = new Map(dependencies.map((file) => [file.id, file])) + for (const expected of prepared.dependencies) + requireSameRevision(currentById.get(expected.id), expected) + const source = context.file + if (!source) throw new OrchestrationError('not_found', 'File not found') + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [ + source, + ...dependencies, + ]) + const secretProvenance = mergeWorkspaceFileSecretProvenance(...evidence.values()) + if ( + input.forModel && + (secretProvenance.status !== 'exact' || secretProvenance.entries.length !== 0) + ) { + throw new OrchestrationError('forbidden', MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE) + } + const folders = source.folderId + ? await listFileFolders(context.owner, { scope: 'all' }, tx) + : [] + const result = { + file: mapFileRecord(source, context.owner, buildWorkspaceFileFolderPathMap(folders)), + buffer: prepared.buffer, + contentType: prepared.contentType, + dependsOnReferencedFiles: prepared.dependsOnReferencedFiles, + secretProvenance, + receipt: createFileReadReceipt(context.owner, [source, ...dependencies]), + } + if (prepared.artifactKey) + renderedSearchArtifacts.set(result, { + owner: context.owner, + file: { fileId: source.id, key: source.key, contentUpdatedAt: source.contentUpdatedAt }, + dependencies: dependencies.map((file) => ({ + fileId: file.id, + key: file.key, + sourceContentUpdatedAt: file.contentUpdatedAt, + })), + artifactKey: prepared.artifactKey, + }) + return result + }, + async afterSuccess({ principal, result }) { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: result.secretProvenance, + view: 'opaque', + }) + await reportWorkspaceFileDelivery(result.secretProvenance, result.file.id) + const completion = renderedSearchArtifacts.get(result) + renderedSearchArtifacts.delete(result) + if (completion) await requeueRenderedSearchArtifact(completion) + }, +}) diff --git a/apps/sim/lib/projects/files/application/authorization.ts b/apps/sim/lib/projects/files/application/authorization.ts index b7fc97ce446..b180ec2fd35 100644 --- a/apps/sim/lib/projects/files/application/authorization.ts +++ b/apps/sim/lib/projects/files/application/authorization.ts @@ -94,7 +94,7 @@ function requirePrincipal( } /** Rechecks canonical conversation ownership and authoring capability while authorization locks are held. */ -async function requireCurrentCopilotProjectInvocation( +export async function requireCurrentCopilotProjectInvocation( tx: DbTransaction, principal: Extract, access: Awaited>, @@ -185,7 +185,7 @@ async function requireCurrentCopilotProjectInvocation( } /** Shared owner policy keeps compound copies subject to the same live Project edit rule. */ -async function requireProjectFileOwnerRole( +export async function requireProjectFileOwnerRole( tx: DbTransaction, principal: ProjectFilePrincipal, access: Awaited>, @@ -215,7 +215,7 @@ async function requireProjectFileOwnerRole( } /** Credential and Files capability checks apply to every currently visible environment. */ -async function requireProjectFileOwnerCapabilities( +export async function requireProjectFileOwnerCapabilities( tx: DbTransaction, principal: ProjectFilePrincipal, access: Awaited>, diff --git a/apps/sim/lib/projects/files/application/downloads.ts b/apps/sim/lib/projects/files/application/downloads.ts new file mode 100644 index 00000000000..2cdeee6a553 --- /dev/null +++ b/apps/sim/lib/projects/files/application/downloads.ts @@ -0,0 +1,503 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' +import { compareStrings } from '@sim/utils/string' +import { and, asc, inArray, isNull, or } from 'drizzle-orm' +import JSZip from 'jszip' +import { requireAdmittedContent } from '@/lib/core/application/content-delivery' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' +import type { DbTransaction } from '@/lib/db/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { readProjectFileArtifact } from '@/lib/projects/files/application/artifacts' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + buildWorkspaceFileFolderPathMap, + loadActiveFileFolderPathIndex, + mapFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs' +import { + createMarkdownExport, + MAX_EXPORT_ASSET_BYTES, + MAX_EXPORT_TOTAL_BYTES, + type MarkdownExportAsset, + type MarkdownExportResult, + MarkdownExportSizeError, +} from '@/lib/uploads/server/markdown-export' +import { bufferZipWithinLimit } from '@/lib/uploads/server/zip' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref' +import { + isMarkdownFile, + MAX_RENDERED_DOCUMENT_BYTES, + needsRenderedArtifact, +} from '@/lib/uploads/utils/file' +import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' +import { + observeWorkspaceFileDelivery, + reportWorkspaceFileDelivery, +} from '@/lib/workspace-files/application/file-delivery-observer' +import { + expandFileDownloadFolders, + normalizeFileDownloadSelection, +} from '@/lib/workspace-files/download-selection' +import { MAX_ZIP_DOWNLOAD_BYTES, MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { + createFileReadReceipt, + type FileReadReceipt, + recheckFileReadReceipt, +} from '@/lib/workspace-files/read-receipt' + +const logger = createLogger('ProjectFileDownloads') + +interface DownloadProjectFileItemsInput extends ProjectFileTarget { + fileIds: string[] + folderIds: string[] +} + +interface DownloadResult { + buffer: Buffer + fileName: string + contentType: 'application/zip' + fileCount: number + secretProvenance: WorkspaceFileSecretProvenance +} + +export interface ExportProjectFileSnapshotInput extends ProjectFileTarget { + fileId: string + content: string + /** Host evidence for an agent's visible snapshot; never accepted by HTTP contracts. */ + secretProvenance?: WorkspaceFileSecretProvenance +} + +async function requireDownloadCapability( + tx: DbTransaction, + principal: Principal, + context: ProjectFileAuthorizationContext, + input: DownloadProjectFileItemsInput +) { + const selection = normalizeFileDownloadSelection(input) + if (selection.fileIds.length === 1 && !selection.folderIds.length) return selection + for (const workspaceId of context.visibleWorkspaceIds) { + // permission-group-enforced: files.bulk_download β€” all accessible active environments govern an archive. + await assertWorkspaceCapability( + requirePrincipalSubjectUserId(principal), + workspaceId, + 'files.bulk_download', + context.organizationId, + tx + ) + } + return selection +} + +async function snapshotSelection( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + input: DownloadProjectFileItemsInput +) { + const selection = normalizeFileDownloadSelection(input) + const index = await loadActiveFileFolderPathIndex(context.owner, tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const folders = [...index.rowById.values()] + const paths = buildWorkspaceFileFolderPathMap(folders) + if (selection.folderIds.some((id) => !index.rowById.has(id))) + throw new OrchestrationError('not_found', 'Folder not found') + const folderIds = expandFileDownloadFolders(selection, folders, paths) + const files = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt), + or( + selection.fileIds.length ? inArray(workspaceFiles.id, selection.fileIds) : undefined, + folderIds.size ? inArray(workspaceFiles.folderId, [...folderIds]) : undefined + ) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .limit(MAX_ZIP_DOWNLOAD_FILES + 1) + .for('share') + if (files.length > MAX_ZIP_DOWNLOAD_FILES) + throw new OrchestrationError( + 'validation', + `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.` + ) + const ids = new Set(files.map((file) => file.id)) + if (selection.fileIds.some((id) => !ids.has(id))) + throw new OrchestrationError('not_found', 'File not found') + if (!files.length) throw new OrchestrationError('validation', 'No files selected for download') + const bytes = files.reduce((total, file) => total + getWorkspaceFileSize(file), 0) + assertKnownSizeWithinLimit(bytes, MAX_ZIP_DOWNLOAD_BYTES, 'selected files') + const identity = JSON.stringify({ + folders: folders + .filter((folder) => folderIds.has(folder.id)) + .sort((left, right) => compareStrings(left.id, right.id)) + .map((folder) => [folder.id, folder.name, folder.parentId]), + files: files.map((file) => [ + file.id, + file.key, + file.contentUpdatedAt.getTime(), + file.originalName, + file.folderId, + file.folderId ? paths.get(file.folderId) : null, + ]), + }) + const provenance = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, files) + return { files, paths, identity, provenance } +} + +const snapshotDownload = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.downloadItems, + DownloadProjectFileItemsInput, + Awaited> +>({ + operation: projectFileOperations.downloadItems, + async execute({ principal, input, context, tx }) { + await requireDownloadCapability(tx, principal, context, input) + return snapshotSelection(tx, context, input) + }, +}) + +interface PreparedDownload { + identity: string + receipts: FileReadReceipt[] + buffer: Buffer + fileCount: number +} + +/** A bounded archive is published only after the selected tree and all rendered inputs remain current. */ +const downloadProjectFileItemsUseCase = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.downloadItems, + DownloadProjectFileItemsInput, + DownloadResult, + PreparedDownload +>({ + operation: projectFileOperations.downloadItems, + async prepare({ principal, input, context }) { + try { + const selection = await snapshotDownload.execute({ principal, input }) + const zip = new JSZip() + const receipts: FileReadReceipt[] = [] + const paths = buildZipEntryPaths( + selection.files.map((file) => ({ + name: file.originalName, + contentType: file.contentType, + folderPath: file.folderId ? selection.paths.get(file.folderId) : null, + })) + ) + let remaining = MAX_ZIP_DOWNLOAD_BYTES + for (const [index, file] of selection.files.entries()) { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: file.id, + provenance: selection.provenance.get(file.id) ?? { status: 'unknown' }, + view: 'opaque', + }) + let buffer: Buffer + if ( + needsRenderedArtifact(file.contentType, file.originalName) || + file.contentType === SIM_PAGE_CONTENT_TYPE || + file.originalName.toLowerCase().endsWith('.html') + ) { + const rendered = await observeWorkspaceFileDelivery( + async () => {}, + () => + readProjectFileArtifact.execute({ + principal, + input: { + projectId: context.projectId, + fileId: file.id, + maxBytes: Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES), + }, + }) + ) + buffer = rendered.buffer + receipts.push(rendered.receipt) + } else { + buffer = await downloadFile({ key: file.key, context: 'project', maxBytes: remaining }) + receipts.push(createFileReadReceipt(context.owner, [file])) + } + assertKnownSizeWithinLimit(buffer.length, remaining, 'selected files') + remaining -= buffer.length + zip.file(paths[index], buffer) + } + return { + identity: selection.identity, + receipts, + fileCount: selection.files.length, + buffer: await bufferZipWithinLimit(zip, MAX_ZIP_DOWNLOAD_BYTES), + } + } catch (error) { + throw asOrchestrationError(error) ?? error + } + }, + async execute({ principal, input, context, tx, prepared }) { + if (!prepared) throw new Error('Prepared archive is unavailable') + await requireDownloadCapability(tx, principal, context, input) + const selection = await snapshotSelection(tx, context, input) + if (selection.identity !== prepared.identity) + throw new OrchestrationError( + 'conflict', + 'File selection changed while preparing the download' + ) + const evidence: WorkspaceFileSecretProvenance[] = [] + for (const receipt of prepared.receipts) + evidence.push(await recheckFileReadReceipt(tx, receipt)) + return { + buffer: prepared.buffer, + fileName: 'project-files.zip', + contentType: 'application/zip', + fileCount: prepared.fileCount, + secretProvenance: mergeWorkspaceFileSecretProvenance(...evidence), + } + }, + afterSuccess: async ({ principal, input, result }) => { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: input.projectId, + provenance: result.secretProvenance, + view: 'opaque', + }) + await reportWorkspaceFileDelivery(result.secretProvenance) + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + description: `Downloaded ${result.fileCount} Project files as zip`, + metadata: { + projectId: context.projectId, + fileCount: result.fileCount, + totalBytes: result.buffer.length, + }, + }), +}) + +/** HEAD validates the same selected resources and capability without preparing archive bytes. */ +export const downloadProjectFileItems = { + ...downloadProjectFileItemsUseCase, + async authorize(args: Parameters[0]) { + await snapshotDownload.execute(args) + }, +} + +interface SnapshotAssets { + source: WorkspaceFileRow + provenance: Map + assets: { reference: string; file: WorkspaceFileRow }[] +} + +const snapshotMarkdownAssets = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.exportSnapshot, + ExportProjectFileSnapshotInput, + SnapshotAssets +>({ + operation: projectFileOperations.exportSnapshot, + async execute({ input, context, tx }) { + const source = context.file + if (!source) throw new OrchestrationError('not_found', 'File not found') + if ( + !isMarkdownFile({ name: source.originalName, type: source.contentType }) && + source.contentType !== 'text/x-markdown' + ) + throw new OrchestrationError('validation', 'Only Markdown files support snapshot export') + if ( + utf8ByteLength(input.content, PASTE_LIMITS.RICH_MARKDOWN_BYTES) > + PASTE_LIMITS.RICH_MARKDOWN_BYTES + ) + throw new OrchestrationError('validation', 'Markdown snapshot is too large') + const references = extractEmbeddedFileRefs(input.content, context.owner) + if (!references.ids.length && !references.keys.length) + return { + source, + assets: [], + provenance: await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [source]), + } + const rows = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt), + or( + references.ids.length + ? inArray(workspaceFiles.id, references.ids.map(storedFileId)) + : undefined, + references.keys.length ? inArray(workspaceFiles.key, references.keys) : undefined + ) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .for('share') + const byId = new Map(rows.map((file) => [file.id, file])) + const byKey = new Map(rows.map((file) => [file.key, file])) + const assets: SnapshotAssets['assets'] = [] + for (const reference of references.ids) { + const file = byId.get(storedFileId(reference)) + if (file) assets.push({ reference, file }) + } + for (const reference of references.keys) { + const file = byKey.get(reference) + if (file) assets.push({ reference, file }) + } + return { + source, + assets, + provenance: await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [source, ...rows]), + } + }, +}) + +interface PreparedSnapshot { + receipt: FileReadReceipt + export: MarkdownExportResult +} + +/** Exports the visible snapshot without advancing the durable file or collaborative document. */ +export const exportProjectFileSnapshot = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.exportSnapshot, + ExportProjectFileSnapshotInput, + MarkdownExportResult & { + file: ReturnType + secretProvenance: WorkspaceFileSecretProvenance + }, + PreparedSnapshot +>({ + operation: projectFileOperations.exportSnapshot, + async prepare({ principal, input, context }) { + const snapshot = await snapshotMarkdownAssets.execute({ principal, input }) + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: snapshot.source.id, + provenance: mergeWorkspaceFileSecretProvenance( + snapshot.provenance.get(snapshot.source.id) ?? { status: 'unknown' }, + input.secretProvenance ?? + (principal.kind === 'resource_delegated' + ? { status: 'unknown' } + : { status: 'exact', entries: [] }) + ), + view: 'opaque', + }) + const content = Buffer.from(input.content) + const assets: MarkdownExportAsset[] = [] + const consumed: WorkspaceFileRow[] = [snapshot.source] + let actualBytes = content.length + try { + assertKnownSizeWithinLimit( + content.length + + snapshot.assets.reduce((total, { file }) => total + getWorkspaceFileSize(file), 0), + MAX_EXPORT_TOTAL_BYTES, + 'Markdown export' + ) + for (const asset of snapshot.assets) { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: asset.file.id, + provenance: snapshot.provenance.get(asset.file.id) ?? { status: 'unknown' }, + view: 'opaque', + }) + let buffer: Buffer + try { + buffer = await downloadFile({ + key: asset.file.key, + context: 'project', + maxBytes: MAX_EXPORT_ASSET_BYTES, + }) + } catch (error) { + logger.warn('Skipped unavailable Markdown export asset', { fileId: asset.file.id, error }) + continue + } + actualBytes += buffer.length + assertKnownSizeWithinLimit(actualBytes, MAX_EXPORT_TOTAL_BYTES, 'Markdown export') + assets.push({ + imageId: asset.reference, + key: asset.file.key, + context: 'project', + originalName: asset.file.originalName, + size: buffer.length, + buffer, + }) + consumed.push(asset.file) + } + return { + receipt: createFileReadReceipt(context.owner, consumed), + export: await createMarkdownExport({ + content, + fileName: snapshot.source.originalName, + owner: context.owner, + assets, + }), + } + } catch (error) { + if (error instanceof MarkdownExportSizeError) + throw new OrchestrationError('validation', error.message) + throw asOrchestrationError(error) ?? error + } + }, + async execute({ principal, input, context, tx, prepared }) { + if (!prepared || !context.file) throw new Error('Prepared Markdown export is unavailable') + const evidence = await recheckFileReadReceipt(tx, prepared.receipt) + const secretProvenance = mergeWorkspaceFileSecretProvenance( + evidence, + input.secretProvenance ?? + (principal.kind === 'resource_delegated' + ? { status: 'unknown' } + : { status: 'exact', entries: [] }) + ) + const index = await loadActiveFileFolderPathIndex(context.owner, tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + return { + ...prepared.export, + secretProvenance, + file: mapFileRecord( + context.file, + context.owner, + buildWorkspaceFileFolderPathMap([...index.rowById.values()]) + ), + } + }, + afterSuccess: async ({ principal, result }) => { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: result.secretProvenance, + view: 'opaque', + }) + await reportWorkspaceFileDelivery(result.secretProvenance, result.file.id) + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Exported Project file "${result.file.name}"`, + metadata: { + projectId: context.projectId, + bytes: result.buffer.length, + format: result.format, + assetCount: result.assetCount, + }, + }), +}) diff --git a/apps/sim/lib/projects/files/application/extract.ts b/apps/sim/lib/projects/files/application/extract.ts new file mode 100644 index 00000000000..19ac3d0da86 --- /dev/null +++ b/apps/sim/lib/projects/files/application/extract.ts @@ -0,0 +1,240 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { describeError, isPostgresCommitRejection } from '@sim/utils/errors' +import { and, eq, isNull } from 'drizzle-orm' +import { maybeNotifyStorageLimitForBillingContext } from '@/lib/billing/storage/tracking' +import { + type AuthorizingUseCase, + recordProjectedUseCaseAuditEntries, +} from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, +} from '@/lib/projects/files/application/authorization' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { prepareProjectFileAccounting } from '@/lib/projects/files/application/write-effects' +import { notifyFileListChanged } from '@/lib/realtime/notify' +import { + archiveFolderName, + MAX_ARCHIVE_BYTES, + prepareArchiveExtraction, +} from '@/lib/uploads/archive' +import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { getBoundWorkspaceFileSecretProvenanceByMetadata } from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { isArchiveFileName } from '@/lib/uploads/utils/file' +import { + commitFileArchiveInTx, + discardFileArchive, + stageFileArchive, +} from '@/lib/workspace-files/archive-extraction' +import { + FILE_EXTRACTION_BUDGET_MS, + requireFileExtractionLeaseInTx, + withFileExtractionLease, +} from '@/lib/workspace-files/extraction-lease' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { lockFileDirectories } from '@/lib/workspace-files/locks' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +const logger = createLogger('ProjectFileExtraction') + +interface ExtractProjectFileInput { + projectId: string + fileId: string +} + +async function snapshotArchive( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + fileId: string +) { + await lockFileDirectories(tx, [context.owner]) + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + eq(workspaceFiles.id, fileId), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('update') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const provenance = (await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])).get( + file.id + ) + if (!provenance) throw new Error('Archive provenance is unavailable') + const parent = await resolveFileFolderTarget(context.owner, { folderId: file.folderId }, tx) + const parentSegments = parent ? parseWorkspaceFileFolderDisplayPath(parent.path) : [] + return { + file, + provenance, + parentSegments, + identity: JSON.stringify({ + key: file.key, + name: file.originalName, + size: getWorkspaceFileSize(file), + contentType: file.contentType, + contentUpdatedAt: file.contentUpdatedAt, + updatedAt: file.updatedAt, + folderId: file.folderId, + parentSegments, + provenance, + }), + } +} + +/** Stages bounded archive members, then atomically publishes their tree under fresh owner authority. */ +export const extractProjectFile: AuthorizingUseCase< + typeof projectFileOperations.extractArchive, + ExtractProjectFileInput, + Awaited> +> = { + operation: projectFileOperations.extractArchive, + delegationAudience: projectFileOperations.extractArchive.delegationAudience, + async authorize(args) { + const authorize = await createProjectFileAuthorizer( + args.principal, + projectFileOperations.extractArchive, + args.input + ) + await db.transaction(authorize) + }, + async execute(args) { + const input = structuredClone(args.input) + const authorize = await createProjectFileAuthorizer( + args.principal, + projectFileOperations.extractArchive, + input + ) + // actorless-unsupported: Project file authorization requires the original human subject. + const userId = requirePrincipalSubjectUserId(args.principal) + const prepared = await db.transaction(async (tx) => { + const context = await authorize(tx) + return { context, snapshot: await snapshotArchive(tx, context, input.fileId) } + }) + const { file } = prepared.snapshot + if (!isArchiveFileName(file.originalName)) + throw new OrchestrationError('validation', 'Only .zip files can be unzipped') + if (getWorkspaceFileSize(file) > MAX_ARCHIVE_BYTES) + throw new OrchestrationError('payload_too_large', 'Archive exceeds the unzip limit') + return withFileExtractionLease(prepared.context.owner, file.id, async (lease) => { + const deadline = AbortSignal.timeout(FILE_EXTRACTION_BUDGET_MS) + const signal = args.request?.signal + ? AbortSignal.any([deadline, args.request.signal]) + : deadline + try { + const staged = await runWithOutboundOrganization( + prepared.context.organizationId, + async () => { + const bytes = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: MAX_ARCHIVE_BYTES, + signal, + }) + if (bytes.length !== getWorkspaceFileSize(file)) + throw new OrchestrationError('conflict', 'Archive bytes changed; retry') + const plan = await prepareArchiveExtraction(bytes, { + rootFolderSegments: [ + ...prepared.snapshot.parentSegments, + archiveFolderName(file.originalName), + ], + includeRootFolder: true, + skipNoiseEntries: true, + signal, + }) + if (!plan.entryCount) + throw new OrchestrationError( + 'validation', + 'No files could be unzipped from this archive' + ) + return stageFileArchive(plan, { owner: prepared.context.owner, userId, signal }) + } + ) + let callbackCompleted = false + let committed + try { + committed = await db.transaction(async (tx) => { + signal.throwIfAborted() + const context = await authorize(tx) + await requireFileExtractionLeaseInTx(tx, lease) + const accounting = await prepareProjectFileAccounting(tx, context) + const current = await snapshotArchive(tx, context, file.id) + if (current.identity !== prepared.snapshot.identity) + throw new OrchestrationError('conflict', 'Archive or destination changed; retry') + const result = await commitFileArchiveInTx(tx, { + owner: context.owner, + userId, + rootName: archiveFolderName(current.file.originalName), + parentId: current.file.folderId, + parentSegments: current.parentSegments, + staged, + secretProvenance: current.provenance, + signal, + }) + const usage = await accounting.mutation.applyDelta(staged.bytes) + signal.throwIfAborted() + await requireFileExtractionLeaseInTx(tx, lease) + callbackCompleted = true + return { context, result, billing: accounting.billing, usage } + }) + } catch (error) { + if (callbackCompleted && !isPostgresCommitRejection(error)) { + logger.error('Archive commit outcome is uncertain; retaining staged content', { + error: describeError(error), + fileId: file.id, + }) + throw error + } + try { + await discardFileArchive(staged) + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Archive publication and cleanup failed' + ) + } + throw error + } + await notifyFileListChanged(committed.context.owner) + recordProjectedUseCaseAuditEntries( + projectFileOperations.extractArchive, + null, + args.principal, + args.request, + [ + { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.originalName, + description: `Unzipped Project file "${file.originalName}"`, + metadata: { projectId: committed.context.projectId, ...committed.result }, + }, + ], + committed.context.organizationId ?? undefined + ) + await maybeNotifyStorageLimitForBillingContext(committed.billing, committed.usage) + return committed.result + } catch (error) { + if (deadline.aborted && error === deadline.reason) + throw new OrchestrationError( + 'payload_too_large', + 'Unzipping took too long and was cancelled. Try a smaller archive.' + ) + throw error + } + }) + }, +} diff --git a/apps/sim/lib/projects/files/application/index.ts b/apps/sim/lib/projects/files/application/index.ts index 65435ae4f2f..4ea278da821 100644 --- a/apps/sim/lib/projects/files/application/index.ts +++ b/apps/sim/lib/projects/files/application/index.ts @@ -1,9 +1,12 @@ +export { readProjectFileArtifact } from './artifacts' export { getProjectFileListAccess } from './collection-access' export { createProjectFile, downloadProjectFileContent, updateProjectFileContent, } from './content' +export { downloadProjectFileItems, exportProjectFileSnapshot } from './downloads' +export { extractProjectFile } from './extract' export { createProjectFileFolder, listProjectFileFolders, updateProjectFileFolder } from './folders' export { archiveProjectFileItems, @@ -13,12 +16,15 @@ export { restoreProjectFileFolder, } from './lifecycle' export { projectFileOperations } from './operations' +export { readProjectFileCsvPreview, readProjectInlineFile } from './previews' export { getProjectFileMetadata, listProjectFileItems, listProjectFiles, resolveProjectFileReference, } from './read' +export { searchProjectFileContent } from './search' +export { getProjectFileShare, updateProjectFileShare } from './shares' export { abortProjectFileUploadSession, completeProjectFileUploadSession, diff --git a/apps/sim/lib/projects/files/application/previews.ts b/apps/sim/lib/projects/files/application/previews.ts new file mode 100644 index 00000000000..d7432c65523 --- /dev/null +++ b/apps/sim/lib/projects/files/application/previews.ts @@ -0,0 +1,173 @@ +import type { Principal } from '@sim/auth/principal' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { requireAdmittedContent } from '@/lib/core/application/content-delivery' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import type { DbTransaction } from '@/lib/db/types' +import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +interface InlineInput { + projectId: string + key?: string + referenceFileId?: string + view?: 'opaque' | 'excerpt' +} + +const resolveInlineRecord = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readInline, + InlineInput, + { + file: WorkspaceFileRow + organizationId: string | null + secretProvenance: WorkspaceFileSecretProvenance | undefined + } +>({ + operation: projectFileOperations.readInline, + async execute({ + input, + context, + tx, + }: { + input: InlineInput + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + if (Boolean(input.key) === Boolean(input.referenceFileId)) + throw new OrchestrationError('validation', 'Provide exactly one file reference') + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt), + input.referenceFileId + ? eq(workspaceFiles.id, input.referenceFileId) + : eq(workspaceFiles.key, input.key ?? '') + ) + ) + .for('share') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file]) + return { + file, + organizationId: context.organizationId, + secretProvenance: evidence.get(file.id), + } + }, + afterSuccess: ({ principal, input, result }) => + requireAdmittedContent(principal, { + kind: 'workspace-file', + id: result.file.id, + provenance: result.secretProvenance ?? { status: 'unknown' }, + view: input.view ?? 'opaque', + }), +}) + +type ResolveInlineArgs = Parameters[0] + +/** + * Streams a private object resolved under its Project, then fences its current head after storage + * opens. A key request names the exact immutable object it streamed, so it is content-addressed. + */ +export const readProjectInlineFile = { + operation: resolveInlineRecord.operation, + delegationAudience: resolveInlineRecord.delegationAudience, + /** HEAD resolves the canonical image without fetching its bytes. */ + async authorize(args: ResolveInlineArgs) { + await resolveInlineRecord.execute(args) + }, + async execute(args: ResolveInlineArgs) { + const snapshot = await resolveInlineRecord.execute(args) + const stream = nodeReadableToWebStream( + await runWithOutboundOrganization(snapshot.organizationId, () => + downloadFileStream({ key: snapshot.file.key, context: 'project' }) + ) + ) + try { + const { file, secretProvenance } = await resolveInlineRecord.execute({ + ...args, + input: { projectId: args.input.projectId, referenceFileId: snapshot.file.id }, + }) + if ( + file.key !== snapshot.file.key || + file.contentUpdatedAt.getTime() !== snapshot.file.contentUpdatedAt.getTime() + ) + throw new OrchestrationError('conflict', 'File changed while preparing its preview') + await reportWorkspaceFileDelivery(secretProvenance, file.id) + return { + file: { id: file.id, name: file.originalName, type: file.contentType }, + stream, + contentLength: getWorkspaceFileSize(file), + contentAddressed: args.input.key !== undefined, + } + } catch (error) { + await stream.cancel(error).catch(() => undefined) + throw error + } + }, +} + +interface CsvPreviewInput extends ProjectFileTarget { + fileId: string + key: string + signal?: AbortSignal +} + +/** Reads only the bounded CSV slice and rejects head changes before exposing its rows. */ +export const readProjectFileCsvPreview = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async prepareFromSnapshot({ + input, + principal, + }: { + principal: Principal + input: CsvPreviewInput + }) { + const { file, organizationId } = await resolveInlineRecord.execute({ + principal, + input: { projectId: input.projectId, referenceFileId: input.fileId, view: 'excerpt' }, + }) + if (file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + return { + file, + slice: await runWithOutboundOrganization(organizationId, () => + getCsvPreviewSlice({ key: file.key, context: 'project', signal: input.signal }) + ), + } + }, + async execute({ input, context, tx, prepared }) { + const file = context.file + if (!file || file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + if (!prepared || file.contentUpdatedAt.getTime() !== prepared.file.contentUpdatedAt.getTime()) + throw new OrchestrationError('conflict', 'File changed while preparing its preview') + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file]) + return { success: true as const, ...prepared.slice, secretProvenance: evidence.get(file.id) } + }, + async afterSuccess({ principal, result, input }) { + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: input.fileId, + provenance: result.secretProvenance ?? { status: 'unknown' }, + view: 'excerpt', + }) + await reportWorkspaceFileDelivery(result.secretProvenance, input.fileId) + }, +}) diff --git a/apps/sim/lib/projects/files/application/search.ts b/apps/sim/lib/projects/files/application/search.ts new file mode 100644 index 00000000000..341d63a9171 --- /dev/null +++ b/apps/sim/lib/projects/files/application/search.ts @@ -0,0 +1,121 @@ +import { getPostgresErrorCode } from '@sim/utils/errors' +import { admitEach, mustClassifyContent } from '@/lib/core/application/content-delivery' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { listFileFolders } from '@/lib/uploads/contexts/workspace' +import { mergeWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { + hasWorkspaceFileDeliveryObserver, + reportWorkspaceFileDelivery, +} from '@/lib/workspace-files/application/file-delivery-observer' +import type { SearchWorkspaceFileContentInput } from '@/lib/workspace-files/application/search-workspace-file-content' +import { + loadFileSearchDelivery, + resolveFileSearchFolderScope, +} from '@/lib/workspace-files/search/delivery' +import { WorkspaceFileSearchUnavailableError } from '@/lib/workspace-files/search/errors' +import { + compileFileSearchPattern, + FileSearchPatternError, +} from '@/lib/workspace-files/search/pattern' +import { type FileSearchResult, searchFileIndex } from '@/lib/workspace-files/search/repository' + +interface SearchProjectFileContentInput + extends Omit { + projectId: string +} + +/** Searches the index, then validates the snapshot against current rows under Project authority. */ +const snapshotSearch = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.searchContent, + SearchProjectFileContentInput, + { prepared: FileSearchResult; delivery: Awaited> }, + FileSearchResult +>({ + operation: projectFileOperations.searchContent, + isolationLevel: 'repeatable read', + async prepare({ input, context, request }) { + const signal = input.signal ?? request?.signal + signal?.throwIfAborted() + const folders = input.folderPaths === undefined ? [] : await listFileFolders(context.owner) + try { + return await searchFileIndex({ + owner: context.owner, + pattern: compileFileSearchPattern(input.query, input.mode), + maxResults: input.maxResults, + folderScope: resolveFileSearchFolderScope(folders, input), + signal, + }) + } catch (error) { + if (error instanceof FileSearchPatternError) + throw new OrchestrationError('validation', error.message) + if (error instanceof WorkspaceFileSearchUnavailableError) + throw new OrchestrationError('locked', error.message) + throw error + } + }, + async execute({ tx, input, context, prepared, request, principal }) { + if (!prepared) throw new Error('Search snapshot is missing') + try { + const folders = + input.folderPaths === undefined ? [] : await listFileFolders(context.owner, {}, tx) + const delivery = await loadFileSearchDelivery(tx, { + owner: context.owner, + classify: mustClassifyContent(principal) || hasWorkspaceFileDeliveryObserver(), + prepared, + scope: resolveFileSearchFolderScope(folders, input), + signal: input.signal ?? request?.signal, + }) + return { prepared, delivery } + } catch (error) { + if (['55P03', '25P04'].includes(getPostgresErrorCode(error) ?? '')) { + throw new OrchestrationError( + 'locked', + 'File search is briefly unavailable. Try again shortly.' + ) + } + throw error + } + }, +}) + +/** Admission runs after the snapshot commits, as on every Project read; row and Project locks would fence nothing. */ +export const searchProjectFileContent = { + operation: snapshotSearch.operation, + delegationAudience: snapshotSearch.delegationAudience, + authorize: snapshotSearch.authorize, + async execute(args: Parameters[0]): Promise { + const { prepared, delivery } = await snapshotSearch.execute(args) + const provenanceOf = (fileId: string) => + delivery.provenance.get(fileId) ?? { status: 'unknown' as const } + const current = prepared.sources.filter((source) => + delivery.current.has(source.identity.fileId) + ) + const admitted = await admitEach(args.principal, current, async (sources) => + sources.map((source) => ({ + kind: 'workspace-file' as const, + id: source.identity.fileId, + provenance: provenanceOf(source.identity.fileId), + view: 'excerpt' as const, + })) + ) + const admittedIds = new Set(admitted.map((source) => source.identity.fileId)) + const results = prepared.results + .filter((hit) => !delivery.outOfScope.has(hit.fileId)) + .map((hit) => + admittedIds.has(hit.fileId) + ? hit + : { + ...hit, + text: '[Preview withheld: read or grep this file to inspect protected text.]', + } + ) + await reportWorkspaceFileDelivery( + mergeWorkspaceFileSecretProvenance( + ...admitted.map((source) => provenanceOf(source.identity.fileId)) + ) + ) + return { ...prepared, results, count: results.length, sources: admitted } + }, +} diff --git a/apps/sim/lib/projects/files/application/shares.ts b/apps/sim/lib/projects/files/application/shares.ts new file mode 100644 index 00000000000..7e810e972fb --- /dev/null +++ b/apps/sim/lib/projects/files/application/shares.ts @@ -0,0 +1,138 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + type FileShareUpdate, + getOwnedFileShare, + ShareValidationError, + upsertOwnedFileShare, +} from '@/lib/public-shares/manager' + +interface ShareTarget extends ProjectFileTarget { + fileId: string +} +interface UpdateShareInput extends ShareTarget, Omit {} + +interface ProjectFileSharePolicy { + canPublish: boolean + allowedAuthTypes: NonNullable[] +} + +async function resolveProjectFileSharePolicy( + principal: Principal, + context: ProjectFileAuthorizationContext, + tx: DbTransaction +): Promise { + const userId = requirePrincipalSubjectUserId(principal) + const configs: Awaited>[] = [] + for (const workspaceId of context.visibleWorkspaceIds) { + configs.push( + await resolvePermissionGroupConfig(userId, workspaceId, context.organizationId, tx) + ) + } + if (configs.length === 0 && context.organizationId) + configs.push(await getUserPermissionConfigForOrganization(context.organizationId, tx)) + const modes = ['public', 'password', 'email', 'sso'] as const + return { + canPublish: configs.every( + (config) => !config || !CAPABILITY_RULES['file_share.publish'].deniedBy(config) + ), + allowedAuthTypes: modes.filter((mode) => + configs.every( + (config) => !config || !CAPABILITY_RULES['file_share.auth_mode'].deniedBy(config, mode) + ) + ), + } +} + +export const getProjectFileShare = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readShare, + ShareTarget, + { + share: Awaited> + policy: ProjectFileSharePolicy + capabilities: { canRead: true; canWrite: boolean } + } +>({ + operation: projectFileOperations.readShare, + async execute({ + principal, + input, + context, + tx, + }: { + principal: Principal + input: ShareTarget + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + return { + share: await getOwnedFileShare(tx, context.owner, input.fileId), + policy: await resolveProjectFileSharePolicy(principal, context, tx), + capabilities: { canRead: true, canWrite: context.canWrite }, + } + }, +}) + +export const updateProjectFileShare = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.updateShare, + UpdateShareInput, + { share: Awaited> } +>({ + operation: projectFileOperations.updateShare, + invalidatesFileList: ({ result }) => result.share !== null, + async execute({ + principal, + input, + context, + tx, + }: { + principal: Principal + input: UpdateShareInput + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + const existing = await getOwnedFileShare(tx, context.owner, input.fileId) + const effectiveAuthType = input.authType ?? existing?.authType ?? 'public' + if (input.isActive) { + const policy = await resolveProjectFileSharePolicy(principal, context, tx) + // permission-group-enforced: file_share.publish β€” the read projection and write gate share every applicable environment policy. + if (!policy.canPublish) refuseCapability('file_share.publish') + // permission-group-enforced: file_share.auth_mode β€” only the intersection of applicable modes may be published. + if (!policy.allowedAuthTypes.includes(effectiveAuthType)) + refuseCapability('file_share.auth_mode') + } + + try { + return { + share: await upsertOwnedFileShare(tx, context.owner, { + ...input, + authType: effectiveAuthType, + userId: requirePrincipalSubjectUserId(principal), + }), + } + } catch (error) { + if (error instanceof ShareValidationError) + throw new OrchestrationError('validation', error.message) + throw error + } + }, + projectAudit: ({ input, context }) => ({ + action: input.isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: input.fileId, + resourceName: context.file?.originalName, + description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for Project file`, + metadata: { projectId: context.projectId }, + }), +}) diff --git a/apps/sim/lib/projects/membership.ts b/apps/sim/lib/projects/membership.ts index 54aa3b522a3..840296c86e2 100644 --- a/apps/sim/lib/projects/membership.ts +++ b/apps/sim/lib/projects/membership.ts @@ -22,17 +22,19 @@ import { ProjectConflictError } from '@/lib/projects/errors' const PROJECT_LOCK_TIMEOUT_MS = 5_000 /** - * Waits in `acquire` are bounded by {@link PROJECT_LOCK_TIMEOUT_MS} and a timeout or - * deadlock becomes a retryable Project conflict. The caller's own `lock_timeout` is - * restored afterwards, so the bound never leaks into the work done under the locks. + * Waits in `acquire` are bounded by `timeoutMs` ({@link PROJECT_LOCK_TIMEOUT_MS} unless a caller + * needs a shorter wait) and a timeout or deadlock becomes a retryable Project conflict. The + * caller's own `lock_timeout` is restored afterwards, so the bound never leaks into the work done + * under the locks. */ async function withProjectLockTimeout( tx: DbTransaction, message: string, - acquire: () => Promise + acquire: () => Promise, + timeoutMs = PROJECT_LOCK_TIMEOUT_MS ): Promise { try { - return await withLockTimeout(tx, PROJECT_LOCK_TIMEOUT_MS, acquire) + return await withLockTimeout(tx, timeoutMs, acquire) } catch (error) { const code = getPostgresErrorCode(error) if (code === '55P03' || code === '40P01') throw new ProjectConflictError(message) @@ -71,9 +73,16 @@ export async function lockProjectBackfillWrites( } /** Canonical Project mutex; membership and lifecycle writers hold it until commit. */ -export async function lockProject(tx: DbTransaction, projectId: string): Promise { - await withProjectLockTimeout(tx, PROJECT_CHANGING, () => - acquireAdvisoryXactLock(tx, 'project', projectLockKey(projectId)) +export async function lockProject( + tx: DbTransaction, + projectId: string, + options: { timeoutMs?: number } = {} +): Promise { + await withProjectLockTimeout( + tx, + PROJECT_CHANGING, + () => acquireAdvisoryXactLock(tx, 'project', projectLockKey(projectId)), + options.timeoutMs ) } diff --git a/apps/sim/lib/public-shares/api/credentials.ts b/apps/sim/lib/public-shares/api/credentials.ts new file mode 100644 index 00000000000..87e44ff1ef6 --- /dev/null +++ b/apps/sim/lib/public-shares/api/credentials.ts @@ -0,0 +1,24 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { getSession } from '@/lib/auth' + +/** Authenticate the optional browser session without substituting the file creator for its caller. */ +export async function publicFileShareCredential( + cookies: readonly { name: string; value: string }[], + clientIp?: string | null +) { + return { + method: 'GET' as const, + cookies: Object.fromEntries(cookies.map(({ name, value }) => [name, value])), + async resolveSessionPrincipal() { + const session = await getSession() + let sessionPrincipal: SessionPrincipal | undefined + if (session?.user?.id) { + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + sessionPrincipal = { kind: 'session', userId: session.user.id, sessionId } + } + return sessionPrincipal + }, + clientIp, + } +} diff --git a/apps/sim/lib/public-shares/api/index.ts b/apps/sim/lib/public-shares/api/index.ts new file mode 100644 index 00000000000..d1f19dddef0 --- /dev/null +++ b/apps/sim/lib/public-shares/api/index.ts @@ -0,0 +1,7 @@ +export { publicFileShareCredential } from './credentials' +export { + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileErrorResponse, +} from './responses' +export { readPublicFileSocialMetadata } from './social-metadata' diff --git a/apps/sim/lib/public-shares/api/responses.ts b/apps/sim/lib/public-shares/api/responses.ts new file mode 100644 index 00000000000..e47a7cd2e4f --- /dev/null +++ b/apps/sim/lib/public-shares/api/responses.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' +import { + asOrchestrationError, + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' + +/** Keep the public cookie endpoints' existing error envelope and retry header. */ +export function publicFileAuthDenied(result: { + error?: string + status?: number + retryAfterMs?: number +}) { + const response = NextResponse.json( + { error: result.error ?? 'auth_required_password' }, + { + status: result.status ?? 401, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) + if (result.status === 429 && result.retryAfterMs !== undefined) + response.headers.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))) + return response +} + +function classifyPublicFileError(error: unknown, fallback: string) { + const classified = asOrchestrationError(error) + return { + code: classified?.code, + status: statusForOrchestrationError(classified?.code), + message: + classified?.code === 'not_found' + ? 'Not found' + : messageForOrchestrationError( + { errorCode: classified?.code, error: classified?.message }, + fallback + ), + } +} + +/** Conceal unavailable targets and driver failures while preserving classified domain failures. */ +export function publicFileErrorResponse(error: unknown, fallback: string) { + const result = classifyPublicFileError(error, fallback) + return NextResponse.json( + { error: result.message }, + { + status: result.status, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) +} + +/** Binary URLs retain the legacy file-error envelope; pending artifacts use their existing challenge. */ +export function publicFileBinaryErrorResponse(error: unknown, fallback: string) { + const result = classifyPublicFileError(error, fallback) + if (result.code === 'conflict') + return NextResponse.json( + { error: result.message }, + { + status: result.status, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) + const name = + result.code === 'not_found' + ? 'FileNotFoundError' + : result.code === 'payload_too_large' + ? 'PayloadSizeLimitError' + : 'Error' + return NextResponse.json( + { error: name, message: result.message }, + { + status: result.status, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) +} diff --git a/apps/sim/lib/public-shares/api/social-metadata.ts b/apps/sim/lib/public-shares/api/social-metadata.ts new file mode 100644 index 00000000000..e78becc6f38 --- /dev/null +++ b/apps/sim/lib/public-shares/api/social-metadata.ts @@ -0,0 +1,14 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { authorizePublicFileShare, readPublicFileShare } from '@/lib/public-shares/application' + +/** Social previews always use anonymous credentials, even when the viewer has an auth cookie. */ +export async function readPublicFileSocialMetadata(token: string) { + try { + const auth = await authorizePublicFileShare({ token, credential: { method: 'GET' } }) + if (!auth.authorized) return { protected: true as const } + return { protected: false as const, metadata: await readPublicFileShare({ grant: auth.grant }) } + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') return null + throw error + } +} diff --git a/apps/sim/lib/public-shares/application/authorization.ts b/apps/sim/lib/public-shares/application/authorization.ts new file mode 100644 index 00000000000..61fa67c7740 --- /dev/null +++ b/apps/sim/lib/public-shares/application/authorization.ts @@ -0,0 +1,259 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { publicShare, user, workspaceFiles } from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' +import { and, eq, isNull } from 'drizzle-orm' +import { isAccountBlocked } from '@/lib/auth/ban' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { deploymentAuthCookieName } from '@/lib/core/security/deployment' +import { + type DeploymentAuthResult, + validateDeploymentCredentials, +} from '@/lib/core/security/deployment-credentials' +import type { DbTransaction } from '@/lib/db/types' +import { + type PublicFileReadOperation, + publicFileOperations, +} from '@/lib/public-shares/application/operations' +import { loadPublicFileOwner } from '@/lib/public-shares/application/owner-adapters' +import { resolveFileOwner } from '@/lib/workspace-files/ownership' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +const grantIdentity = Symbol('verified-public-file-share') +export interface VerifiedPublicFileShareGrant { + readonly [grantIdentity]: true +} +interface PublicFileCredential { + method: 'GET' | 'POST' + password?: string + email?: string + cookies?: Readonly> + resolveSessionPrincipal?: () => Promise + sessionPrincipal?: Principal + clientIp?: string | null +} +interface SessionIdentity { + userId: string + email: string +} +interface GrantState { + token: string + policy: string + session?: SessionIdentity + authenticatedEmail?: string + expiresAt: number +} +const grants = new WeakMap() + +function unavailable(): never { + throw new OrchestrationError('not_found', 'File share not found') +} + +async function loadSnapshot(tx: DbTransaction, token: string) { + const [discovered] = await tx + .select({ share: publicShare, file: workspaceFiles }) + .from(publicShare) + .innerJoin(workspaceFiles, eq(publicShare.resourceId, workspaceFiles.id)) + .where(and(eq(publicShare.token, token), eq(publicShare.resourceType, 'file'))) + .limit(1) + if (!discovered) unavailable() + const owner = resolveFileOwner(discovered.file) + if ( + !owner || + discovered.share.entityType !== owner.entityType || + discovered.share.entityId !== owner.entityId + ) + unavailable() + const context = await loadPublicFileOwner(tx, owner) + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, discovered.file.id), + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + if (!file) unavailable() + const [share] = await tx + .select() + .from(publicShare) + .where( + and( + eq(publicShare.id, discovered.share.id), + eq(publicShare.token, token), + eq(publicShare.resourceType, 'file'), + eq(publicShare.resourceId, file.id), + eq(publicShare.entityType, context.owner.entityType), + eq(publicShare.entityId, context.owner.entityId), + eq(publicShare.isActive, true) + ) + ) + .for('share') + if ( + !share || + share.workspaceId !== (context.owner.entityType === 'workspace' ? context.owner.entityId : null) + ) + unavailable() + const [creator] = file.userId + ? await tx.select({ name: user.name }).from(user).where(eq(user.id, file.userId)).limit(1) + : [] + return { ...context, file, share, creatorName: creator?.name ?? null } +} +export type PublicFileShareSnapshot = Awaited> + +function policy(snapshot: PublicFileShareSnapshot) { + const { share, owner } = snapshot + return JSON.stringify([ + share.id, + share.token, + share.resourceId, + owner, + share.authType, + share.password, + share.allowedEmails, + ]) +} +async function loadSession( + tx: DbTransaction, + userId: string +): Promise { + const [row] = await tx + .select({ + id: user.id, + email: user.email, + banned: user.banned, + banExpires: user.banExpires, + suspendedAt: user.suspendedAt, + }) + .from(user) + .where(eq(user.id, userId)) + .for('share') + return row && !isAccountBlocked(row) ? { userId: row.id, email: row.email } : undefined +} + +/** Exchanges real credentials for an in-process, short-lived bearer grant bound to current sharing policy. */ +export async function authorizePublicFileShare({ + token, + credential, +}: { + token: string + credential: PublicFileCredential +}): Promise< + | (DeploymentAuthResult & { authorized: false; authType: string }) + | { + authorized: true + authType: string + grant: VerifiedPublicFileShareGrant + authenticatedEmail?: string + } +> { + try { + if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) unavailable() + const snapshot = await db.transaction((tx) => loadSnapshot(tx, token)) + const principal = + snapshot.share.authType === 'sso' + ? (credential.sessionPrincipal ?? (await credential.resolveSessionPrincipal?.())) + : undefined + const session = + principal?.kind === 'session' + ? await db.transaction((tx) => loadSession(tx, principal.userId)) + : undefined + const initial = { snapshot, session } + const result = await validateDeploymentCredentials( + generateShortId(), + initial.snapshot.share, + { + method: credential.method, + authToken: + credential.cookies?.[deploymentAuthCookieName('file', initial.snapshot.share.id)], + clientIp: credential.clientIp, + sessionPresent: Boolean(initial.session), + sessionEmail: initial.session?.email, + }, + credential.method === 'POST' + ? { password: credential.password, email: credential.email } + : undefined, + 'file' + ) + if (!result.authorized) + return { ...result, authorized: false, authType: initial.snapshot.share.authType } + const state: GrantState = { + token, + policy: policy(initial.snapshot), + session: initial.session, + authenticatedEmail: result.authenticatedEmail, + expiresAt: Date.now() + 60_000, + } + await db.transaction((tx) => validateState(tx, state)) + const grant: VerifiedPublicFileShareGrant = Object.freeze({ [grantIdentity]: true as const }) + grants.set(grant, state) + return { + authorized: true, + grant, + authType: initial.snapshot.share.authType, + authenticatedEmail: result.authenticatedEmail, + } + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +async function validateState(tx: DbTransaction, state: GrantState) { + if (state.expiresAt <= Date.now()) unavailable() + const snapshot = await loadSnapshot(tx, state.token) + if (policy(snapshot) !== state.policy) unavailable() + if (state.session) { + const session = await loadSession(tx, state.session.userId) + if (!session || session.email !== state.session.email) unavailable() + } + return snapshot +} + +/** A credential challenge authorizes only verification of this live token's declared authentication mode. */ +export async function preparePublicFileShareChallenge( + token: string, + authType: 'password' | 'email' | 'sso' +) { + if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) unavailable() + const initial = await db.transaction((tx) => loadSnapshot(tx, token)) + if (initial.share.authType !== authType) { + const message = + authType === 'sso' + ? 'This file is not configured for SSO' + : `This file does not use ${authType} authentication` + throw new OrchestrationError('validation', message) + } + const state: GrantState = { token, policy: policy(initial), expiresAt: Date.now() + 60_000 } + return Object.freeze({ + shareId: initial.share.id, + async withCurrentPolicy( + execute: (snapshot: PublicFileShareSnapshot) => Promise | T + ): Promise { + try { + return await db.transaction(async (tx) => execute(await validateState(tx, state))) + } catch (error) { + throw asOrchestrationError(error) ?? error + } + }, + }) +} + +/** Every bearer read rechecks current token, owner, file lifetime, and verified session policy. */ +export async function withPublicFileShareGrant( + grant: VerifiedPublicFileShareGrant, + operation: PublicFileReadOperation, + execute: (tx: DbTransaction, snapshot: PublicFileShareSnapshot) => Promise +): Promise { + try { + if (!Object.values(publicFileOperations).some((registered) => registered === operation)) + throw new Error('Unregistered public file operation') + const state = grants.get(grant) + if (!state) unavailable() + return await db.transaction(async (tx) => execute(tx, await validateState(tx, state))) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} diff --git a/apps/sim/lib/public-shares/application/credentials.ts b/apps/sim/lib/public-shares/application/credentials.ts new file mode 100644 index 00000000000..e8036c4e50e --- /dev/null +++ b/apps/sim/lib/public-shares/application/credentials.ts @@ -0,0 +1,165 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { normalizeEmail } from '@sim/utils/string' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' +import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { createDeploymentAuthCookie, isEmailAllowed } from '@/lib/core/security/deployment' +import { + decodeOTPValue, + deleteOTP, + generateOTP, + getOTP, + incrementOTPAttempts, + MAX_OTP_ATTEMPTS, + OTP_EMAIL_RATE_LIMIT, + OTP_RESOURCE_RATE_LIMIT, + storeOTP, +} from '@/lib/core/security/otp' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { + authorizePublicFileShare, + preparePublicFileShareChallenge, +} from '@/lib/public-shares/application/authorization' + +const logger = createLogger('PublicFileShareCredentials') +const rateLimiter = new RateLimiter() +const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} +const OTP_FAILURE = { authorized: false as const, error: 'Invalid verification code', status: 400 } + +/** Exchanges a verified password for a cookie bound to the share's current password slot. */ +export async function authenticatePublicFileSharePassword({ + token, + password, + clientIp, +}: { + token: string + password: string + clientIp?: string | null +}) { + const challenge = await preparePublicFileShareChallenge(token, 'password') + const result = await authorizePublicFileShare({ + token, + credential: { method: 'POST', password, clientIp }, + }) + if (!result.authorized) return result + const resource = await challenge.withCurrentPolicy(({ share }) => share) + const cookie = await createDeploymentAuthCookie({ cookiePrefix: 'file', resource }) + await challenge.withCurrentPolicy(() => undefined) + return { authorized: true as const, authType: 'password' as const, cookie } +} + +/** Schedules code delivery without revealing whether the supplied email is on the allow-list. */ +export async function requestPublicFileShareOtp({ + token, + email, +}: { + token: string + email: string +}) { + const normalizedEmail = normalizeEmail(email) + const challenge = await preparePublicFileShareChallenge(token, 'email') + return { + async deliver(): Promise { + const allowed = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizedEmail, share.allowedEmails) + ) + if (!allowed) return + const resourceLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:resource:${challenge.shareId}`, + OTP_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceLimit.allowed) return + const emailLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:email:${challenge.shareId}:${normalizedEmail}`, + OTP_EMAIL_RATE_LIMIT, + { failClosed: true } + ) + if (!emailLimit.allowed) return + const otp = generateOTP() + await storeOTP('file', challenge.shareId, normalizedEmail, otp) + const html = await renderOTPEmail(otp, 'email-verification', 'a shared file') + await challenge.withCurrentPolicy(() => undefined) + const result = await sendEmail({ + to: normalizedEmail, + subject: getOtpSubject('a shared file'), + html, + }) + if (!result.success) + logger.error('Failed to send public file verification code', { + shareId: challenge.shareId, + message: result.message, + }) + }, + } +} + +/** Consumes the existing email code and issues a cookie only while its original sharing policy remains current. */ +export async function verifyPublicFileShareOtp({ + token, + email, + otp, +}: { + token: string + email: string + otp: string +}) { + const normalizedEmail = normalizeEmail(email) + const challenge = await preparePublicFileShareChallenge(token, 'email') + const allowed = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizedEmail, share.allowedEmails) + ) + if (!allowed) return OTP_FAILURE + const value = await getOTP('file', challenge.shareId, normalizedEmail) + await challenge.withCurrentPolicy(() => undefined) + if (!value) return OTP_FAILURE + const stored = decodeOTPValue(value) + if (stored.attempts >= MAX_OTP_ATTEMPTS) { + await deleteOTP('file', challenge.shareId, normalizedEmail) + return OTP_FAILURE + } + if (!safeCompare(stored.otp, otp)) { + await incrementOTPAttempts('file', challenge.shareId, normalizedEmail, value) + return OTP_FAILURE + } + await deleteOTP('file', challenge.shareId, normalizedEmail) + const resource = await challenge.withCurrentPolicy(({ share }) => share) + const cookie = await createDeploymentAuthCookie({ + cookiePrefix: 'file', + resource, + verifiedEmail: normalizedEmail, + }) + await challenge.withCurrentPolicy(() => undefined) + return { authorized: true as const, authType: 'email' as const, cookie } +} + +/** Checks allow-list eligibility for the SSO redirect without granting access or asserting session identity. */ +export async function getPublicFileShareSsoEligibility({ + token, + email, +}: { + token: string + email: string +}) { + const challenge = await preparePublicFileShareChallenge(token, 'sso') + const limit = await rateLimiter.checkRateLimitDirect( + `file-sso:resource:${challenge.shareId}`, + SSO_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!limit.allowed) + return { + allowed: false as const, + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limit.retryAfterMs ?? SSO_RESOURCE_RATE_LIMIT.refillIntervalMs, + } + const eligible = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizeEmail(email), share.allowedEmails) + ) + return { allowed: true as const, eligible } +} diff --git a/apps/sim/lib/public-shares/application/index.ts b/apps/sim/lib/public-shares/application/index.ts new file mode 100644 index 00000000000..b722ee4c0d5 --- /dev/null +++ b/apps/sim/lib/public-shares/application/index.ts @@ -0,0 +1,13 @@ +export { authorizePublicFileShare } from './authorization' +export { + authenticatePublicFileSharePassword, + getPublicFileShareSsoEligibility, + requestPublicFileShareOtp, + verifyPublicFileShareOtp, +} from './credentials' +export { + checkPublicFileShareContent, + readPublicFileShare, + readPublicFileShareContent, + readPublicFileShareInline, +} from './read' diff --git a/apps/sim/lib/public-shares/application/operations.ts b/apps/sim/lib/public-shares/application/operations.ts new file mode 100644 index 00000000000..740ab3d2938 --- /dev/null +++ b/apps/sim/lib/public-shares/application/operations.ts @@ -0,0 +1,35 @@ +import { + type ApplicationOperation, + assertOperationCapability, +} from '@/lib/core/application/operation' + +interface PublicFileOperation extends ApplicationOperation { + readonly authority: 'verified_public_file_share' +} +function definePublicFileOperation(operation: O): O { + assertOperationCapability(operation) + return Object.freeze(operation) +} + +export const publicFileOperations = { + // permission-group-exempt: verified share credentials authorize bearer reads; member publishing enforces sharing policy. + readMetadata: definePublicFileOperation({ + id: 'public_files.read_metadata', + authority: 'verified_public_file_share', + capability: 'none', + }), + // permission-group-exempt: verified share credentials authorize bearer reads; member publishing enforces sharing policy. + readContent: definePublicFileOperation({ + id: 'public_files.read_content', + authority: 'verified_public_file_share', + capability: 'none', + }), + // permission-group-exempt: the verified share grant covers only current same-owner images referenced by its file. + readInline: definePublicFileOperation({ + id: 'public_files.read_inline', + authority: 'verified_public_file_share', + capability: 'none', + }), +} as const +export type PublicFileReadOperation = + (typeof publicFileOperations)[keyof typeof publicFileOperations] diff --git a/apps/sim/lib/public-shares/application/owner-adapters.ts b/apps/sim/lib/public-shares/application/owner-adapters.ts new file mode 100644 index 00000000000..6715bf34422 --- /dev/null +++ b/apps/sim/lib/public-shares/application/owner-adapters.ts @@ -0,0 +1,112 @@ +import { project, type WorkspaceFileRow, workspace } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { lockProjectShared, lockWorkspaceProject } from '@/lib/projects/membership' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' +import { + type E2BDocFormat, + fileDocumentCacheIdentity, + resolveServableDoc, +} from '@/lib/uploads/documents/compile' +import { loadCompiledDoc } from '@/lib/uploads/documents/compiled-store' +import { fileDocumentInputIdentity } from '@/lib/uploads/documents/input-identity' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { EditableFileOwner, FileOwner } from '@/lib/workspace-files/ownership' + +interface PublicFileOwnerContext { + owner: EditableFileOwner + organizationId: string | null + displayName: string + workspaceId: string | null +} +interface DocumentRead { + owner: EditableFileOwner + source: Buffer + sourceMime: string + fileName: string + format: E2BDocFormat + dependencies: readonly WorkspaceFileRow[] +} +interface PublicFileOwnerAdapter { + load(tx: DbTransaction, entityId: string): Promise + storageContext: 'workspace' | 'project' + pageOptions(entityId: string): { workspaceId?: string; projectId?: string } + readCompiled(input: DocumentRead): Promise<{ buffer: Buffer; contentType: string }> +} + +const adapters: FileOwnerAdapters = { + workspace: { + async load(tx, entityId) { + await lockWorkspaceProject(tx, entityId) + const [row] = await tx.select().from(workspace).where(eq(workspace.id, entityId)).for('share') + if (!row || row.archivedAt) throw new OrchestrationError('not_found', 'File share not found') + return { + owner: { entityType: 'workspace', entityId }, + organizationId: row.organizationId, + displayName: row.name, + workspaceId: entityId, + } + }, + storageContext: 'workspace', + pageOptions: (workspaceId) => ({ workspaceId }), + async readCompiled({ owner, source, sourceMime, fileName }) { + const artifact = await resolveServableDoc(owner.entityId, source, fileName, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + sourceMime, + }) + if (artifact.kind !== 'artifact') + throw new OrchestrationError( + 'conflict', + 'This document is still being prepared. Please try again shortly.' + ) + return artifact + }, + }, + project: { + async load(tx, entityId) { + await requireProjectApiEnabled() + await lockProjectShared(tx, entityId) + const [row] = await tx.select().from(project).where(eq(project.id, entityId)).for('share') + if (!row || row.archivedAt) throw new OrchestrationError('not_found', 'File share not found') + return { + owner: { entityType: 'project', entityId }, + organizationId: row.organizationId, + displayName: row.name, + workspaceId: null, + } + }, + storageContext: 'project', + pageOptions: (projectId) => ({ projectId }), + async readCompiled({ owner, source, sourceMime, format, dependencies }) { + const buffer = await loadCompiledDoc( + owner, + source.toString('utf8'), + format.ext, + fileDocumentCacheIdentity( + source.toString('utf8'), + format, + sourceMime, + fileDocumentInputIdentity(owner, dependencies) + ), + { maxBytes: MAX_BUFFERED_TRANSFER_BYTES } + ) + if (!buffer) + throw new OrchestrationError( + 'conflict', + 'This document is still being prepared. Please try again shortly.' + ) + return { buffer, contentType: format.contentType } + }, + }, +} + +/** Bearer policy requires a live canonical owner; it never substitutes a member or creator identity. */ +export async function loadPublicFileOwner(tx: DbTransaction, owner: FileOwner) { + const adapter = requireFileOwnerAdapter(adapters, owner) + return { ...(await adapter.load(tx, owner.entityId)), adapter } +} diff --git a/apps/sim/lib/public-shares/application/read.ts b/apps/sim/lib/public-shares/application/read.ts new file mode 100644 index 00000000000..9ff4ef28c7a --- /dev/null +++ b/apps/sim/lib/public-shares/application/read.ts @@ -0,0 +1,435 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { + asOrchestrationError, + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import type { DbTransaction } from '@/lib/db/types' +import { + type PublicFileShareSnapshot, + type VerifiedPublicFileShareGrant, + withPublicFileShareGrant, +} from '@/lib/public-shares/application/authorization' +import { + type PublicFileReadOperation, + publicFileOperations, +} from '@/lib/public-shares/application/operations' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { downloadFile, headObject } from '@/lib/uploads/core/storage-service' +import { + collectReferencedFileIds, + getDocumentSourceLanguage, + getE2BDocFormat, + isCompiledDocumentBuffer, +} from '@/lib/uploads/documents/compile' +import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' +import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file' +import { sniffImageContentType } from '@/lib/uploads/utils/validation' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { renderSimPageDocument } from '@/lib/workspace-files/page-document' +import { + collectSimPageFileReferences, + inlineSimPageImages, +} from '@/lib/workspace-files/page-document.server' + +interface PublicReadInput { + grant: VerifiedPublicFileShareGrant + request?: OrchestrationRequestContext +} +interface PublicManifest { + snapshot: PublicFileShareSnapshot + dependencies: WorkspaceFileRow[] + pageHtml?: string +} + +function present(snapshot: PublicFileShareSnapshot) { + const { file } = snapshot + return { + owner: snapshot.owner, + ownerName: snapshot.creatorName, + workspaceName: snapshot.displayName, + file: { + id: file.id, + originalName: file.originalName, + contentType: file.contentType, + sizeBytes: file.sizeBytes, + uploadedBy: file.userId, + updatedAt: file.updatedAt, + }, + } +} +function requireRevision(current: WorkspaceFileRow, expected: WorkspaceFileRow) { + if ( + current.id !== expected.id || + current.key !== expected.key || + current.contentUpdatedAt.getTime() !== expected.contentUpdatedAt.getTime() + ) + throw new OrchestrationError('conflict', 'Shared file changed during the read') +} +function requirePublicBytes(provenance: WorkspaceFileSecretProvenance) { + if ( + provenance.status === 'unknown' || + (provenance.status === 'exact' && provenance.entries.length > 0) + ) + throw new OrchestrationError('forbidden', 'File cannot be shared safely') +} +async function admitPublicFiles(tx: DbTransaction, files: readonly WorkspaceFileRow[]) { + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, files) + const provenance = mergeWorkspaceFileSecretProvenance(...evidence.values()) + requirePublicBytes(provenance) + return provenance +} +async function dependencies( + tx: DbTransaction, + snapshot: PublicFileShareSnapshot, + ids: readonly string[] +) { + if (ids.length > 500) + throw new OrchestrationError('payload_too_large', 'Too many document inputs') + if (ids.length === 0) return [] + const rows = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(snapshot.owner), + inArray(workspaceFiles.id, [...ids]), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .for('share') + if (rows.length !== ids.length) + throw new OrchestrationError('not_found', 'Document input not found') + await admitPublicFiles(tx, rows) + return rows +} +async function finish( + grant: VerifiedPublicFileShareGrant, + manifest: PublicManifest, + operation: PublicFileReadOperation, + buffer: Buffer, + contentType: string, + request?: OrchestrationRequestContext, + servedFile: WorkspaceFileRow = manifest.snapshot.file +) { + assertKnownSizeWithinLimit(buffer.length, MAX_BUFFERED_TRANSFER_BYTES, 'shared file') + const result = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, manifest.snapshot.file) + const current = await dependencies( + tx, + snapshot, + manifest.dependencies.map((file) => file.id) + ) + const expected = new Map(manifest.dependencies.map((file) => [file.id, file])) + for (const file of current) { + const prior = expected.get(file.id) + if (!prior) throw new OrchestrationError('not_found', 'Document input not found') + requireRevision(file, prior) + } + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [ + snapshot.file, + ...current, + ]) + requirePublicBytes(mergeWorkspaceFileSecretProvenance(...evidence.values())) + return { + ...present(snapshot), + buffer, + contentType, + servedFileName: servedFile.originalName, + secretProvenance: mergeWorkspaceFileSecretProvenance(...evidence.values()), + organizationId: snapshot.organizationId, + } + }) + await reportWorkspaceFileDelivery(result.secretProvenance, servedFile.id) + recordAudit({ + actorId: null, + workspaceId: manifest.snapshot.workspaceId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: servedFile.id, + resourceName: servedFile.originalName, + description: `Public share download of "${servedFile.originalName}"`, + request, + metadata: { + organizationId: result.organizationId, + operation: operation.id, + sharedDocumentId: result.file.id, + sharedByUserId: manifest.snapshot.file.userId, + access: 'public_share', + anonymous: true, + owner: result.owner, + bytes: buffer.length, + }, + }) + return result +} + +/** Metadata is disclosed only after the current token policy has produced a verified bearer grant. */ +export function readPublicFileShare({ grant }: PublicReadInput) { + return withPublicFileShareGrant(grant, publicFileOperations.readMetadata, async (_tx, snapshot) => + present(snapshot) + ) +} + +/** Checks cached representation availability without rendering or recording a download. */ +export async function checkPublicFileShareContent( + grant: VerifiedPublicFileShareGrant +): Promise { + const operation = publicFileOperations.readContent + try { + const initial = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + await admitPublicFiles(tx, [snapshot.file]) + return snapshot + }) + const stored = await headObject(initial.file.key, initial.adapter.storageContext) + if (!stored) throw new OrchestrationError('not_found', 'File not found') + assertKnownSizeWithinLimit(stored.size, MAX_BUFFERED_TRANSFER_BYTES, 'File') + const format = await getE2BDocFormat(initial.file.originalName) + let inputs: WorkspaceFileRow[] = [] + if (format) { + const source = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + if (!isCompiledDocumentBuffer(initial.file.originalName, source)) { + const text = source.toString('utf8') + const ids = [ + ...new Set( + collectReferencedFileIds( + text, + isDocSandboxEnabled + ? getDocumentSourceLanguage(text, format, initial.file.contentType) + : 'javascript' + ) + ), + ] + inputs = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, initial.file) + return dependencies(tx, snapshot, ids) + }) + const artifact = await initial.adapter.readCompiled({ + owner: initial.owner, + source, + sourceMime: initial.file.contentType, + fileName: initial.file.originalName, + format, + dependencies: inputs, + }) + assertKnownSizeWithinLimit( + artifact.buffer.length, + MAX_BUFFERED_TRANSFER_BYTES, + 'shared file' + ) + } + } + await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, initial.file) + const current = await dependencies( + tx, + snapshot, + inputs.map((file) => file.id) + ) + await admitPublicFiles(tx, [snapshot.file, ...current]) + for (let index = 0; index < current.length; index++) { + requireRevision(current[index], inputs[index]) + } + }) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +/** A public read never executes generated document code; it serves current cached artifacts only. */ +export async function readPublicFileShareContent({ + grant, + preview, + request, +}: PublicReadInput & { preview?: boolean }) { + const operation = publicFileOperations.readContent + try { + const initial = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + await admitPublicFiles(tx, [snapshot.file]) + return snapshot + }) + const raw = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + const format = await getE2BDocFormat(initial.file.originalName) + const generated = Boolean(format && !isCompiledDocumentBuffer(initial.file.originalName, raw)) + const source = + generated || + initial.file.contentType === SIM_PAGE_CONTENT_TYPE || + initial.file.originalName.toLowerCase().endsWith('.html') + ? raw.toString('utf8') + : '' + const manifest = await withPublicFileShareGrant( + grant, + operation, + async (tx, snapshot): Promise => { + requireRevision(snapshot.file, initial.file) + const pageHtml = + (snapshot.file.contentType === SIM_PAGE_CONTENT_TYPE || + snapshot.file.originalName.toLowerCase().endsWith('.html')) && + isSimPageSource(source) + ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + : undefined + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + if ( + references.some( + (ref) => + ref.projectId && + (snapshot.owner.entityType !== 'project' || ref.projectId !== snapshot.owner.entityId) + ) + ) + throw new OrchestrationError('not_found', 'Document input not found') + const ids = [ + ...new Set( + pageHtml + ? references.map((ref) => ref.fileId) + : generated && format + ? collectReferencedFileIds( + source, + isDocSandboxEnabled + ? getDocumentSourceLanguage(source, format, initial.file.contentType) + : 'javascript' + ) + : [] + ), + ] + return { snapshot, pageHtml, dependencies: await dependencies(tx, snapshot, ids) } + } + ) + let buffer = raw + let contentType = resolveEffectiveMimeType(undefined, initial.file.originalName) + if (manifest.pageHtml) { + const byId = new Map(manifest.dependencies.map((file) => [file.id, file])) + const page = await inlineSimPageImages( + manifest.pageHtml, + async ({ fileId }, maxBytes) => { + const file = byId.get(fileId) + if (!file) throw new OrchestrationError('not_found', 'Document input not found') + const bytes = await downloadFile({ + key: file.key, + context: initial.adapter.storageContext, + maxBytes, + }) + const mime = sniffImageContentType(bytes) + if (!mime) throw new OrchestrationError('not_found', 'Embedded image not found') + return { bytes, contentType: mime, identity: file.id } + }, + { strict: true } + ) + buffer = Buffer.from(page.html) + contentType = 'text/html' + } else if (generated && format) { + ;({ buffer, contentType } = await initial.adapter.readCompiled({ + owner: initial.owner, + source: raw, + sourceMime: initial.file.contentType, + fileName: initial.file.originalName, + format, + dependencies: manifest.dependencies, + })) + } else if (preview) { + const image = await resolveServableImageBytes(raw, initial.file.key, { owner: initial.owner }) + if (image) ({ buffer, contentType } = image) + } + return await finish(grant, manifest, operation, buffer, contentType, request) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +/** A share grants only current image embeds in its own document and canonical owner. */ +export async function readPublicFileShareInline({ + grant, + fileId, + key, + request, +}: PublicReadInput & { fileId?: string; key?: string }) { + const operation = publicFileOperations.readInline + try { + if (Boolean(fileId) === Boolean(key)) + throw new OrchestrationError('validation', 'Select one image reference') + const initial = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + await admitPublicFiles(tx, [snapshot.file]) + return snapshot + }) + const raw = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: 10 * 1024 * 1024, + }).catch((error: unknown) => { + if (isPayloadSizeLimitError(error)) + throw new OrchestrationError('not_found', 'Embedded image not found') + throw error + }) + const source = raw.toString('utf8') + const manifest = await withPublicFileShareGrant( + grant, + operation, + async (tx, snapshot): Promise => { + requireRevision(snapshot.file, initial.file) + const pageHtml = isSimPageSource(source) + ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + : undefined + const reference = fileId ? { fileId } : key ? { key } : null + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + const embedded = + reference && + (pageHtml + ? references.some( + (ref) => + ref.fileId === fileId && + (!ref.projectId || + (snapshot.owner.entityType === 'project' && + ref.projectId === snapshot.owner.entityId)) + ) + : hasEmbeddedFileRef(source, reference, snapshot.owner)) + if (!embedded) throw new OrchestrationError('not_found', 'Embedded image not found') + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(snapshot.owner), + isNull(workspaceFiles.deletedAt), + fileId ? eq(workspaceFiles.id, fileId) : eq(workspaceFiles.key, key ?? '') + ) + ) + .for('share') + if (!file) throw new OrchestrationError('not_found', 'Embedded image not found') + await admitPublicFiles(tx, [snapshot.file, file]) + return { snapshot, dependencies: [file] } + } + ) + const target = manifest.dependencies[0] + if (!target) throw new OrchestrationError('not_found', 'Embedded image not found') + const buffer = await downloadFile({ + key: target.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + const contentType = sniffImageContentType(buffer) + if (!contentType) throw new OrchestrationError('not_found', 'Embedded image not found') + return await finish(grant, manifest, operation, buffer, contentType, request, target) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} diff --git a/apps/sim/lib/public-shares/manager.ts b/apps/sim/lib/public-shares/manager.ts index 52940830088..8fbf4b52b15 100644 --- a/apps/sim/lib/public-shares/manager.ts +++ b/apps/sim/lib/public-shares/manager.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { publicShare, user, type WorkspaceFileRow, workspace, workspaceFiles } from '@sim/db/schema' +import { publicShare, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -9,10 +9,12 @@ import type { ShareRecord, shareResourceTypeSchema, } from '@/lib/api/contracts/public-shares' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { DbTransaction } from '@/lib/db/types' import { lockWorkspaceProject } from '@/lib/projects/membership' -import { resolveFileOwner } from '@/lib/workspace-files/ownership' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' const logger = createLogger('PublicShareManager') @@ -141,8 +143,7 @@ export async function getWorkspaceShares( return result } -interface UpsertFileShareInput { - workspaceId: string +export interface FileShareUpdate { fileId: string userId: string isActive: boolean @@ -167,162 +168,146 @@ interface UpsertFileShareInput { * Disabling (going Private) always succeeds and preserves the stored config so a * later re-enable restores it. Validation failures throw {@link ShareValidationError}. */ -export async function upsertFileShare({ - workspaceId, - fileId, - userId, - isActive, - authType, - password, - allowedEmails, - token, -}: UpsertFileShareInput): Promise { +export async function upsertFileShare( + input: FileShareUpdate & { workspaceId: string } +): Promise { return db.transaction(async (tx) => { - await lockWorkspaceProject(tx, workspaceId) - const [file] = await tx - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.id, fileId), - fileOwnerCondition({ entityType: 'workspace', entityId: workspaceId }), - isNull(workspaceFiles.deletedAt) - ) - ) - .for('update') - if (!file) throw new ShareValidationError('File not found') - const [existing] = await tx - .select() - .from(publicShare) - .where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId))) - .limit(1) - - const finalAuthType: ShareAuthType = - authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public' - const existingAllowedEmails = Array.isArray(existing?.allowedEmails) - ? (existing.allowedEmails as string[]) - : [] - - // Disabling preserves the stored config (and skips validation) so turning - // sharing off always succeeds; only enabling validates the chosen auth mode. - let finalPassword: string | null = existing?.password ?? null - let finalAllowedEmails: string[] = existingAllowedEmails - if (isActive) { - if (finalAuthType === 'password') { - if (password) { - finalPassword = (await encryptSecret(password)).encrypted - } else if (existing?.password) { - finalPassword = existing.password - } else { - throw new ShareValidationError('Password is required for password-protected shares') - } - finalAllowedEmails = [] - } else if (finalAuthType === 'email' || finalAuthType === 'sso') { - finalAllowedEmails = allowedEmails ?? existingAllowedEmails - if (finalAllowedEmails.length === 0) { - throw new ShareValidationError( - 'At least one allowed email is required for email/SSO shares' - ) - } - finalPassword = null - } else { - finalPassword = null - finalAllowedEmails = [] - } - } - - const [row] = await tx - .insert(publicShare) - .values({ - id: generateId(), - resourceType: 'file', - resourceId: fileId, - workspaceId, - entityType: 'workspace', - entityId: workspaceId, - createdBy: userId, - token: token ?? generateShortId(), - isActive, - authType: finalAuthType, - password: finalPassword, - allowedEmails: finalAllowedEmails, - }) - .onConflictDoUpdate({ - target: [publicShare.resourceType, publicShare.resourceId], - set: { - isActive, - authType: finalAuthType, - password: finalPassword, - allowedEmails: finalAllowedEmails, - updatedAt: new Date(), - }, - }) - .returning() - - logger.info('Upserted file share', { - fileId, - workspaceId, - isActive, - authType: finalAuthType, - }) - return mapShareRecord(row) + await lockWorkspaceProject(tx, input.workspaceId) + return upsertOwnedFileShare(tx, { entityType: 'workspace', entityId: input.workspaceId }, input) }) } -/** - * Resolve a public token to its active share and the underlying (non-deleted) - * file. Returns null if the token is unknown, the share is inactive, or the file - * is gone. The caller treats null as a 404 β€” the existence of a file is never - * leaked through this path. - */ -export interface ResolvedShare { - share: PublicShareRow & { workspaceId: string } - file: WorkspaceFileRow & { workspaceId: string } - /** Owning workspace name, for provenance on the public page. */ - workspaceName: string | null - /** Display name of the file's uploader. */ - ownerName: string | null -} - -export async function resolveActiveShareByToken(token: string): Promise { - const [row] = await db - .select({ - share: publicShare, - file: workspaceFiles, - workspaceName: workspace.name, - ownerName: user.name, - }) +/** Reads a share only within its canonical file owner; authorization belongs to the caller. */ +export async function getOwnedFileShare( + tx: DbTransaction, + owner: EditableFileOwner, + fileId: string +): Promise { + const [row] = await tx + .select() .from(publicShare) - .innerJoin(workspaceFiles, eq(workspaceFiles.id, publicShare.resourceId)) - .leftJoin(workspace, eq(workspace.id, workspaceFiles.workspaceId)) - .leftJoin(user, eq(user.id, workspaceFiles.userId)) .where( and( - eq(publicShare.token, token), - eq(publicShare.isActive, true), eq(publicShare.resourceType, 'file'), + eq(publicShare.resourceId, fileId), + eq(publicShare.entityType, owner.entityType), + eq(publicShare.entityId, owner.entityId) + ) + ) + .limit(1) + return row ? mapShareRecord(row) : null +} + +/** Reuses share configuration and token lifetime for every registered editable owner. */ +export async function upsertOwnedFileShare( + tx: DbTransaction, + owner: EditableFileOwner, + { fileId, userId, isActive, authType, password, allowedEmails, token }: FileShareUpdate +): Promise { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + eq(workspaceFiles.id, fileId), isNull(workspaceFiles.deletedAt) ) ) + .for('update') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const [existing] = await tx + .select() + .from(publicShare) + .where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId))) .limit(1) - if (!row || !row.file.workspaceId) return null - const owner = resolveFileOwner(row.file) if ( - owner?.entityType !== 'workspace' || - owner.entityId !== row.file.workspaceId || - row.share.workspaceId !== owner.entityId || - !( - (row.share.entityType === null && row.share.entityId === null) || - (row.share.entityType === 'workspace' && row.share.entityId === owner.entityId) - ) + existing && + (existing.entityType !== owner.entityType || existing.entityId !== owner.entityId) ) - return null + throw new OrchestrationError('not_found', 'File share not found') - return { - share: { ...row.share, workspaceId: owner.entityId }, - file: { ...row.file, workspaceId: owner.entityId }, - workspaceName: row.workspaceName, - ownerName: row.ownerName, + const finalAuthType: ShareAuthType = + authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public' + const existingAllowedEmails = Array.isArray(existing?.allowedEmails) + ? (existing.allowedEmails as string[]) + : [] + + // Disabling preserves the stored config (and skips validation) so turning + // sharing off always succeeds; only enabling validates the chosen auth mode. + let finalPassword: string | null = existing?.password ?? null + let finalAllowedEmails: string[] = existingAllowedEmails + if (isActive) { + if (!['public', 'password', 'email', 'sso'].includes(finalAuthType)) + throw new ShareValidationError('Invalid share authentication mode') + if (password !== undefined && (password.length < 15 || password.length > 1024)) + throw new ShareValidationError('Password must be between 15 and 1024 characters') + if ( + allowedEmails && + (allowedEmails.length > 200 || + allowedEmails.some((email) => email.length < 1 || email.length > 320)) + ) + throw new ShareValidationError('Invalid allowed email list') + if (!existing && token !== undefined && !/^[A-Za-z0-9_-]{16,64}$/.test(token)) + throw new ShareValidationError('Invalid share token') + if (finalAuthType === 'password') { + if (password) { + finalPassword = (await encryptSecret(password)).encrypted + } else if (existing?.password) { + finalPassword = existing.password + } else { + throw new ShareValidationError('Password is required for password-protected shares') + } + finalAllowedEmails = [] + } else if (finalAuthType === 'email' || finalAuthType === 'sso') { + finalAllowedEmails = allowedEmails ?? existingAllowedEmails + if (finalAllowedEmails.length === 0) { + throw new ShareValidationError( + 'At least one allowed email is required for email/SSO shares' + ) + } + finalPassword = null + } else { + finalPassword = null + finalAllowedEmails = [] + } } + + const [row] = await tx + .insert(publicShare) + .values({ + id: generateId(), + resourceType: 'file', + resourceId: fileId, + workspaceId: owner.entityType === 'workspace' ? owner.entityId : null, + entityType: owner.entityType, + entityId: owner.entityId, + createdBy: userId, + token: token ?? generateShortId(), + isActive, + authType: finalAuthType, + password: finalPassword, + allowedEmails: finalAllowedEmails, + }) + .onConflictDoUpdate({ + target: [publicShare.resourceType, publicShare.resourceId], + set: { + isActive, + authType: finalAuthType, + password: finalPassword, + allowedEmails: finalAllowedEmails, + updatedAt: new Date(), + }, + }) + .returning() + + logger.info('Upserted file share', { + fileId, + owner, + isActive, + authType: finalAuthType, + }) + return mapShareRecord(row) } diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index e6924847366..cf182d56cb1 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -154,17 +154,27 @@ type InflateResult = const inflateEntryWithinCaps = ( entry: JSZip.JSZipObject, remainingTotalBudget: number, - retain: boolean + retain: boolean, + signal?: AbortSignal ): Promise => new Promise((resolve, reject) => { + signal?.throwIfAborted() const chunks: Buffer[] = [] let size = 0 let settled = false const stream = entry.nodeStream() as Readable + const abort = () => { + if (settled) return + settled = true + stream.destroy() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) const settle = (result: InflateResult) => { if (settled) return settled = true + signal?.removeEventListener('abort', abort) stream.destroy() resolve(result) } @@ -187,6 +197,7 @@ const inflateEntryWithinCaps = ( stream.on('error', () => { if (settled) return settled = true + signal?.removeEventListener('abort', abort) stream.destroy() // A stream error here means the entry's compressed data is corrupt or // truncated (it passed the central-directory parse) β€” surface it under the @@ -258,70 +269,43 @@ function throwInflateCapError(reason: 'entry' | 'total', entryName: string): nev ) } -/** - * Decompress an archive buffer into workspace files under `rootFolderSegments` - * (default: the workspace root). Reuses the same caps and zip-slip / zip-bomb / - * symlink guards everywhere. Throws {@link ArchiveError} for an invalid archive - * or a cap violation; returns `{ extracted: [] }` when every entry was skipped. - * - * All-or-nothing: pass 1 inflates every entry through a discarding counting sink - * to enforce the per-entry and total caps on REAL inflated bytes (declared sizes - * can lie), and nothing is uploaded until the whole archive has passed β€” so a - * mid-archive violation never leaves a partial tree in the workspace. Pass 2 - * re-inflates and uploads one entry at a time. Peak memory stays ~one entry in - * both passes; the cost is inflating twice (CPU only, bounded by the caps). - * - * `signal` aborts between entries in both passes. Callers that hold a lease or run - * under a request deadline must pass one: the write loop is otherwise unbounded, and a - * process killed mid-pass-2 strands a partial tree that no `catch` can roll back. - * Aborting instead unwinds through the same all-or-nothing rollback as any other failure. - * - * When `prepareRootFolder` is provided it takes precedence over - * `rootFolderSegments`: it runs once, only after the caps have been proven and - * only when at least one safe entry exists, and extraction lands under the - * segments it returns. It must create exactly one folder, and must pass its - * final segments to the supplied validator before inserting it so the complete - * destination path is rejected before any folder mutation. That one folder is - * counted by the `maxMaterializedItems` pre-check (files + implied folders + - * root folder), which rejects an over-limit archive before the callback - * materializes anything. That cap always applies β€” it defaults to - * {@link MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS} β€” because the file count alone - * does not bound folder creation. - * - * Filesystem-noise entries (`__MACOSX/`, `.DS_Store`, `Thumbs.db`) are extracted - * verbatim unless `skipNoiseEntries` is set β€” the HTTP decompress route preserves - * them; the agent-facing extract path drops them. Decompression is not byte-preserving, - * so every extracted file inherits the archive's full candidate set. Runtime consumers redact - * decoded content; direct opaque delivery still refuses known secret contributions. - */ -export async function decompressArchiveBufferToWorkspaceFiles( +/** Derives a safe, human-readable extraction root from the archive name. */ +export function archiveFolderName(fileName: string): string { + const stripped = fileName + .replace(/\.zip$/i, '') + .normalize('NFC') + .replace(/[\x00-\x1f\x7f]/g, '') + .replace(/[/\\]/g, '-') + .trim() + return stripped && stripped !== '.' && stripped !== '..' ? stripped : 'archive' +} + +export interface PreparedArchiveExtraction { + readonly entryCount: number + readonly skipped: number + readonly skippedUnsafePaths: readonly string[] + validateRootFolderSegments(segments: readonly string[]): void + entries(signal?: AbortSignal): AsyncGenerator<{ segments: readonly string[]; buffer: Buffer }> +} + +/** Validates one bounded archive before any destination mutation or storage upload. */ +export async function prepareArchiveExtraction( buffer: Buffer, - opts: { - workspaceId: string - principal: Principal - rootFolderSegments?: string[] - prepareRootFolder?: ( - validateRootFolderSegments: (rootFolderSegments: string[]) => void - ) => Promise + options: { + rootFolderSegments?: readonly string[] + includeRootFolder?: boolean signal?: AbortSignal maxMaterializedItems?: number skipNoiseEntries?: boolean - secretProvenance?: WorkspaceFileSecretProvenance - notifyWorkspaceChange?: boolean - } -): Promise { + } = {} +): Promise { const { - workspaceId, - principal, rootFolderSegments = [], - prepareRootFolder, + includeRootFolder = false, signal, maxMaterializedItems = MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS, skipNoiseEntries = false, - secretProvenance = { status: 'unknown' }, - notifyWorkspaceChange = true, - } = opts - + } = options assertCentralDirWithinCaps(buffer) let zip: JSZip @@ -362,7 +346,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( ) } - const validateRootFolderSegments = (candidateRootFolderSegments: string[]): void => { + const validateRootFolderSegments = (candidateRootFolderSegments: readonly string[]): void => { for (const { entry, segments } of safeEntries) { try { buildFolderPath([...candidateRootFolderSegments, ...segments.slice(0, -1)]) @@ -388,13 +372,13 @@ export async function decompressArchiveBufferToWorkspaceFiles( } /** * Bounds the whole output tree, not just the file count: 1000 entries nested 64 deep imply - * far more folders than files, and nothing else caps folder creation. `prepareRootFolder` + * far more folders than files, and nothing else caps folder creation. `includeRootFolder` * contributes exactly one more folder when it runs. */ const materializedItems = safeEntries.length + impliedFolderPaths.size + - (safeEntries.length > 0 && prepareRootFolder ? 1 : 0) + (safeEntries.length > 0 && includeRootFolder ? 1 : 0) if (materializedItems > maxMaterializedItems) { throw new ArchiveError( 'too_many_entries', @@ -420,14 +404,112 @@ export async function decompressArchiveBufferToWorkspaceFiles( const result = await inflateEntryWithinCaps( entry, MAX_ARCHIVE_TOTAL_BYTES - validatedTotal, - false + false, + signal ) if (!result.ok) throwInflateCapError(result.reason, entry.name) validatedTotal += result.size } + return Object.freeze({ + entryCount: safeEntries.length, + skipped, + skippedUnsafePaths: Object.freeze(skippedUnsafePaths), + validateRootFolderSegments, + async *entries(entrySignal = signal) { + let totalBytes = 0 + for (const { entry, segments } of safeEntries) { + entrySignal?.throwIfAborted() + const result = await inflateEntryWithinCaps( + entry, + MAX_ARCHIVE_TOTAL_BYTES - totalBytes, + true, + entrySignal + ) + if (!result.ok) throwInflateCapError(result.reason, entry.name) + if (!result.buffer) throw new Error('Archive entry bytes are unavailable') + totalBytes += result.size + yield { segments: Object.freeze(segments), buffer: result.buffer } + } + }, + }) +} + +/** + * Decompress an archive buffer into workspace files under `rootFolderSegments` + * (default: the workspace root). Reuses the same caps and zip-slip / zip-bomb / + * symlink guards everywhere. Throws {@link ArchiveError} for an invalid archive + * or a cap violation; returns `{ extracted: [] }` when every entry was skipped. + * + * All-or-nothing: pass 1 inflates every entry through a discarding counting sink + * to enforce the per-entry and total caps on REAL inflated bytes (declared sizes + * can lie), and nothing is uploaded until the whole archive has passed β€” so a + * mid-archive violation never leaves a partial tree in the workspace. Pass 2 + * re-inflates and uploads one entry at a time. Peak memory stays ~one entry in + * both passes; the cost is inflating twice (CPU only, bounded by the caps). + * + * `signal` aborts between entries in both passes. Callers that hold a lease or run + * under a request deadline must pass one: the write loop is otherwise unbounded, and a + * process killed mid-pass-2 strands a partial tree that no `catch` can roll back. + * Aborting instead unwinds through the same all-or-nothing rollback as any other failure. + * + * When `prepareRootFolder` is provided it takes precedence over + * `rootFolderSegments`: it runs once, only after the caps have been proven and + * only when at least one safe entry exists, and extraction lands under the + * segments it returns. It must create exactly one folder, and must pass its + * final segments to the supplied validator before inserting it so the complete + * destination path is rejected before any folder mutation. That one folder is + * counted by the `maxMaterializedItems` pre-check (files + implied folders + + * root folder), which rejects an over-limit archive before the callback + * materializes anything. That cap always applies β€” it defaults to + * {@link MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS} β€” because the file count alone + * does not bound folder creation. + * + * Filesystem-noise entries (`__MACOSX/`, `.DS_Store`, `Thumbs.db`) are extracted + * verbatim unless `skipNoiseEntries` is set β€” the HTTP decompress route preserves + * them; the agent-facing extract path drops them. Decompression is not byte-preserving, + * so every extracted file inherits the archive's full candidate set. Runtime consumers redact + * decoded content; direct opaque delivery still refuses known secret contributions. + */ +export async function decompressArchiveBufferToWorkspaceFiles( + buffer: Buffer, + opts: { + workspaceId: string + principal: Principal + rootFolderSegments?: string[] + prepareRootFolder?: ( + validateRootFolderSegments: (rootFolderSegments: string[]) => void + ) => Promise + signal?: AbortSignal + maxMaterializedItems?: number + skipNoiseEntries?: boolean + secretProvenance?: WorkspaceFileSecretProvenance + notifyWorkspaceChange?: boolean + } +): Promise { + const { + workspaceId, + principal, + rootFolderSegments = [], + prepareRootFolder, + signal, + maxMaterializedItems = MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS, + skipNoiseEntries = false, + secretProvenance = { status: 'unknown' }, + notifyWorkspaceChange = true, + } = opts + + const prepared = await prepareArchiveExtraction(buffer, { + rootFolderSegments, + includeRootFolder: Boolean(prepareRootFolder), + signal, + maxMaterializedItems, + skipNoiseEntries, + }) + const { validateRootFolderSegments, skipped } = prepared + const skippedUnsafePaths = [...prepared.skippedUnsafePaths] const resolvedRootFolderSegments = - safeEntries.length > 0 && prepareRootFolder + prepared.entryCount > 0 && prepareRootFolder ? await prepareRootFolder(validateRootFolderSegments) : rootFolderSegments // Re-check what the callback actually returned; identical segments were proven above. @@ -447,14 +529,9 @@ export async function decompressArchiveBufferToWorkspaceFiles( const createdFolderIds: string[] = [] const createdFiles: WorkspaceFileRecord[] = [] const extracted: UserFile[] = [] - let totalBytes = 0 try { - for (const { entry, segments } of safeEntries) { + for await (const { segments, buffer: entryBuffer } of prepared.entries(signal)) { signal?.throwIfAborted() - const result = await inflateEntryWithinCaps(entry, MAX_ARCHIVE_TOTAL_BYTES - totalBytes, true) - if (!result.ok) throwInflateCapError(result.reason, entry.name) - totalBytes += result.size - const entryBuffer = result.buffer as Buffer const leafName = segments[segments.length - 1] const folderSegments = [...resolvedRootFolderSegments, ...segments.slice(0, -1)] diff --git a/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts index 3c7cb2b96f6..cb42467105f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-folder-manager.ts @@ -679,11 +679,21 @@ export async function createWorkspaceFileFolder( return workspaceFolderRecord(folder) } -/** Creates one folder under the caller's owner transaction and the shared hierarchy lock. */ +/** Creates one folder and hydrates its display path for single-item callers. */ export async function createFileFolder( params: CreateFileFolderParams, tx: DbTransaction ): Promise> { + const inserted = await createFileFolderRow(params, tx) + const paths = buildWorkspaceFileFolderPathMap(await selectFileFolderPathRows(params.owner, tx)) + return mapFileFolder(inserted, params.owner, paths) +} + +/** Uses canonical folder validation and locking without rebuilding paths during a bulk mutation. */ +export async function createFileFolderRow( + params: CreateFileFolderParams, + tx: DbTransaction +): Promise { const requestedName = normalizeWorkspaceFileItemName(params.name, 'Folder') await acquireFileFolderMutationLock(tx, params.owner) const parentId = await assertFileFolderTarget(params.owner, params.parentId, tx) @@ -745,8 +755,7 @@ export async function createFileFolder( if (getPostgresErrorCode(error) === '23505') throw new WorkspaceFileFolderConflictError(name) throw error } - const paths = buildWorkspaceFileFolderPathMap(await selectFileFolderPathRows(params.owner, tx)) - return mapFileFolder(inserted, params.owner, paths) + return inserted } /** diff --git a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts index 2b973a65d81..b14ce6e3d62 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts @@ -506,7 +506,7 @@ export interface PlannedFileIdentity { const plannedFileOwners = new WeakMap>() /** Reserves an in-process destination identity so a compound copy can rewrite selected references. */ -function planFileIdentity(owner: EditableFileOwner): PlannedFileIdentity { +export function planFileIdentity(owner: EditableFileOwner): PlannedFileIdentity { const identity = Object.freeze({ id: `wf_${generateShortId()}` }) plannedFileOwners.set(identity, Object.freeze({ ...owner })) return identity diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts index 485841ec286..d23713fea8f 100644 --- a/apps/sim/lib/uploads/core/storage-client.ts +++ b/apps/sim/lib/uploads/core/storage-client.ts @@ -11,32 +11,15 @@ export function getServePathPrefix(): string { } /** - * Get file metadata from storage provider + * Read raw object metadata from the configured provider; it is not canonical file ownership. * @param key File key/name * @param customConfig Optional custom storage configuration * @returns File metadata object with userId, workspaceId, originalName, uploadedAt, etc. */ -export async function getFileMetadata( +export async function getStorageObjectMetadata( key: string, customConfig?: StorageConfig ): Promise> { - const { getFileMetadataByKey } = await import('../server/metadata') - const metadataRecord = await getFileMetadataByKey(key) - - if (metadataRecord) { - return { - ...(metadataRecord.projectId == null && - metadataRecord.context !== 'project' && - metadataRecord.userId - ? { userId: metadataRecord.userId } - : {}), - workspaceId: metadataRecord.workspaceId || '', - originalName: metadataRecord.originalName, - uploadedAt: metadataRecord.uploadedAt.toISOString(), - purpose: metadataRecord.context, - } - } - if (USE_BLOB_STORAGE) { const { headBlobObject } = await import('@/lib/uploads/providers/blob/client') const { BLOB_CONFIG } = await import('@/lib/uploads/config') diff --git a/apps/sim/lib/uploads/documents/compile.test.ts b/apps/sim/lib/uploads/documents/compile.test.ts index 13a2e6c42c2..698ea4479b1 100644 --- a/apps/sim/lib/uploads/documents/compile.test.ts +++ b/apps/sim/lib/uploads/documents/compile.test.ts @@ -39,8 +39,13 @@ vi.mock('@/lib/uploads/documents/compiled-store', async (importOriginal) => ({ })) import { type ContentDeliveryGate, observeContentDelivery } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { createCopilotChatFilePrincipal } from '@/lib/mothership/auth/file-delegation' -import { collectReferencedFileIds, compileDoc } from '@/lib/uploads/documents/compile' +import { + collectReferencedFileIds, + compileDoc, + compileFileDocument, +} from '@/lib/uploads/documents/compile' import { DocCompileUserError } from '@/lib/uploads/documents/compile-error' const executeInSandboxMock = remoteSandboxMockFns.mockExecuteInSandbox @@ -317,6 +322,44 @@ describe('collectReferencedFileIds', () => { }) }) +function referencedFileVersion(at: string) { + const contentUpdatedAt = new Date(at) + return { + id: ID, + workspaceId: 'workspace-1', + name: 'reference.png', + key: `workspace/workspace-1/${ID}-reference.png`, + path: '/api/files/serve/reference.png', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: contentUpdatedAt, + updatedAt: contentUpdatedAt, + contentUpdatedAt, + storageContext: 'workspace' as const, + } +} + +/** A sandbox build that reads the referenced file through its broker, as the isolated VM does. */ +function buildReadingReferencedFile() { + runSandboxTaskMock.mockImplementation( + async ( + _task: string, + _input: unknown, + options: { onWorkspaceFileAccess?: (identity: unknown) => void } + ) => { + const { file } = await readWorkspaceFileMetadataMock() + options.onWorkspaceFileAccess?.({ + fileId: ID, + key: file.key, + context: 'workspace', + contentUpdatedAt: file.contentUpdatedAt, + }) + return Buffer.from('%PDF-built') + } + ) +} + describe('isolated-VM compile for Chat', () => { beforeEach(() => { setEnvFlags({ isDocSandboxEnabled: false }) @@ -333,39 +376,7 @@ describe('isolated-VM compile for Chat', () => { return sources.map(() => true) }, } - const version = (at: string) => { - const contentUpdatedAt = new Date(at) - return { - id: ID, - workspaceId: 'workspace-1', - name: 'reference.png', - key: `workspace/workspace-1/${ID}-reference.png`, - path: '/api/files/serve/reference.png', - size: 10, - type: 'image/png', - uploadedBy: 'user-1', - uploadedAt: contentUpdatedAt, - updatedAt: contentUpdatedAt, - contentUpdatedAt, - storageContext: 'workspace' as const, - } - } - runSandboxTaskMock.mockImplementation( - async ( - _task: string, - _input: unknown, - options: { onWorkspaceFileAccess?: (identity: unknown) => void } - ) => { - const { file } = await readWorkspaceFileMetadataMock() - options.onWorkspaceFileAccess?.({ - fileId: ID, - key: file.key, - context: 'workspace', - contentUpdatedAt: file.contentUpdatedAt, - }) - return Buffer.from('%PDF-built') - } - ) + buildReadingReferencedFile() const compile = () => observeContentDelivery(admitAll, () => compileDoc({ @@ -376,7 +387,7 @@ describe('isolated-VM compile for Chat', () => { }) ) readWorkspaceFileMetadataMock.mockResolvedValue({ - file: version('2026-08-05T00:00:00.000Z'), + file: referencedFileVersion('2026-08-05T00:00:00.000Z'), share: null, }) await compile() @@ -384,10 +395,105 @@ describe('isolated-VM compile for Chat', () => { expect(runSandboxTaskMock).toHaveBeenCalledOnce() readWorkspaceFileMetadataMock.mockResolvedValue({ - file: version('2026-08-06T00:00:00.000Z'), + file: referencedFileVersion('2026-08-06T00:00:00.000Z'), share: null, }) await compile() expect(runSandboxTaskMock).toHaveBeenCalledTimes(2) }) }) + +describe('isolated-VM compile for a session', () => { + beforeEach(() => { + setEnvFlags({ isDocSandboxEnabled: false }) + }) + + const compile = (marker: string) => + compileDoc({ + source: `image = await getFileBase64('${ID}') // ${marker}`, + fileName: 'report.pdf', + workspaceId: 'workspace-1', + filePrincipal: FILE_PRINCIPAL, + }) + + it('rebuilds a cached build whose referenced file was edited, reporting the current version', async () => { + buildReadingReferencedFile() + readWorkspaceFileMetadataMock.mockResolvedValue({ + file: referencedFileVersion('2026-08-05T00:00:00.000Z'), + share: null, + }) + await compile('session-edited') + await compile('session-edited') + expect(runSandboxTaskMock).toHaveBeenCalledOnce() + + const edited = referencedFileVersion('2026-08-06T00:00:00.000Z') + readWorkspaceFileMetadataMock.mockResolvedValue({ file: edited, share: null }) + const rebuilt = await compile('session-edited') + expect(runSandboxTaskMock).toHaveBeenCalledTimes(2) + expect(rebuilt.contributingFiles).toEqual([ + expect.objectContaining({ fileId: ID, contentUpdatedAt: edited.contentUpdatedAt }), + ]) + }) + + it('rebuilds a cached build whose referenced file was deleted', async () => { + buildReadingReferencedFile() + readWorkspaceFileMetadataMock.mockResolvedValue({ + file: referencedFileVersion('2026-08-05T00:00:00.000Z'), + share: null, + }) + await compile('session-deleted') + readWorkspaceFileMetadataMock.mockRejectedValueOnce( + new OrchestrationError('not_found', 'File not found') + ) + await compile('session-deleted') + expect(runSandboxTaskMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('compileFileDocument source engine', () => { + it('uses the JavaScript PDF task even with the document sandbox enabled', async () => { + setEnvFlags({ isDocSandboxEnabled: true }) + loadCompiledDocMock.mockResolvedValue(null) + runSandboxTaskMock.mockResolvedValue(Buffer.from('%PDF-1.7')) + runSandboxTaskMock.mockClear() + executeInSandboxMock.mockClear() + const source = 'const doc = await PDFDocument.create()' + const result = await compileFileDocument({ + owner: { entityType: 'project', entityId: ID }, + source, + sourceMime: 'text/x-pdflibjs', + fileName: 'report.pdf', + inputs: [], + maxBytes: 1024, + }) + expect(result.buffer.toString()).toBe('%PDF-1.7') + expect(runSandboxTaskMock).toHaveBeenCalledWith( + 'pdf-generate', + { code: source }, + expect.any(Object) + ) + expect(executeInSandboxMock).not.toHaveBeenCalled() + }) +}) + +it('separates cached PDF artifacts by effective source dialect', async () => { + setEnvFlags({ isDocSandboxEnabled: true }) + const identities: string[] = [] + loadCompiledDocMock.mockImplementation(async (_owner, _source, _ext, identity) => { + identities.push(identity) + return Buffer.from(identity.includes('javascript') ? '%PDF-JS' : '%PDF-Python') + }) + const input = { + owner: { entityType: 'project' as const, entityId: ID }, + source: 'shared source', + fileName: 'report.pdf', + inputs: [], + maxBytes: 1024, + } + const js = await compileFileDocument({ ...input, sourceMime: 'text/x-pdflibjs' }) + const python = await compileFileDocument({ ...input, sourceMime: 'text/x-python' }) + expect(js.buffer.toString()).toBe('%PDF-JS') + expect(python.buffer.toString()).toBe('%PDF-Python') + expect(js.artifactKey).not.toBe(python.artifactKey) + expect(identities[0]).not.toBe(identities[1]) +}) diff --git a/apps/sim/lib/uploads/documents/compile.ts b/apps/sim/lib/uploads/documents/compile.ts index 915e2ee123a..a0cd08f73d6 100644 --- a/apps/sim/lib/uploads/documents/compile.ts +++ b/apps/sim/lib/uploads/documents/compile.ts @@ -2,8 +2,11 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { compareStrings } from '@sim/utils/string' -import { ContentRefusedError, mustClassifyContent } from '@/lib/core/application' +import { LRUCache } from 'lru-cache' +import { ContentRefusedError } from '@/lib/core/application' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox, @@ -35,6 +38,7 @@ import { } from '@/lib/workspace-files/application/admit-file-content' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import type { SandboxTaskId } from '@/sandboxes/registry' const logger = createLogger('CopilotDocCompile') @@ -55,7 +59,7 @@ const PYTHON_XLSX_SOURCE_MIME = 'text/x-python-xlsx' type DocEngine = 'node' | 'python' -interface E2BDocFormat { +export interface E2BDocFormat { ext: 'pptx' | 'docx' | 'pdf' | 'xlsx' engine: DocEngine formatName: 'PPTX' | 'DOCX' | 'PDF' | 'XLSX' @@ -618,6 +622,101 @@ async function renderPreparedDocument(source: string, fmt: E2BDocFormat, inputs: : compileDocViaE2BPython(source, fmt, inputs) } +/** Shared identity for owner-bound rendering and readers of its compiled artifacts. */ +export function fileDocumentCacheIdentity( + source: string, + format: E2BDocFormat, + sourceMime: string, + inputIdentity?: string +): string { + return JSON.stringify({ + sourceLanguage: getDocumentSourceLanguage(source, format, sourceMime), + renderer: + isDocSandboxEnabled && sourceMime !== 'text/x-pdflibjs' ? 'document-sandbox' : 'native', + inputs: inputIdentity ?? null, + }) +} + +/** Compiles a document against a fixed, authorized input manifest and canonical owner cache. */ +export async function compileFileDocument(args: { + owner: EditableFileOwner + sourceMime: string + source: string + fileName: string + inputs: readonly { fileId: string; contentType: string; content: Buffer }[] + inputIdentity?: string + maxBytes: number + signal?: AbortSignal +}): Promise { + const fmt = await getE2BDocFormat(args.fileName) + if (!fmt) throw new DocCompileUserError(`Unsupported document format: ${args.fileName}`) + if ( + args.inputs.length > MAX_REFERENCED_INPUTS || + args.inputs.some((input) => input.content.length > MAX_STAGED_FILE_BYTES) || + args.inputs.reduce((sum, input) => sum + input.content.length, 0) > MAX_STAGED_TOTAL_BYTES + ) { + throw new DocCompileUserError('Document inputs exceed the rendering limit') + } + const useDocumentSandbox = isDocSandboxEnabled && args.sourceMime !== 'text/x-pdflibjs' + const cacheIdentity = fileDocumentCacheIdentity( + args.source, + fmt, + args.sourceMime, + args.inputIdentity + ) + const cached = await loadCompiledDoc(args.owner, args.source, fmt.ext, cacheIdentity, { + maxBytes: args.maxBytes, + signal: args.signal, + }) + if (cached) + return { + buffer: cached, + artifactKey: compiledArtifactKey(args.owner, args.source, fmt.ext, cacheIdentity), + contentType: fmt.contentType, + dependsOnReferencedFiles: args.inputs.length > 0, + } + let buffer: Buffer + if (useDocumentSandbox) { + buffer = await renderPreparedDocument( + args.source, + fmt, + args.inputs.map((input) => ({ + path: `/home/user/inputs/${input.fileId}`, + content: input.content.toString('base64'), + encoding: 'base64' as const, + })) + ) + } else { + const format = COMPILABLE_FORMATS[`.${fmt.ext}`] + if (!format || isSimdocDeckSource(args.source)) { + throw new DocCompileUserError('This document format requires the document sandbox') + } + const inputs = new Map(args.inputs.map((input) => [input.fileId, input])) + buffer = await runSandboxTask( + format.taskId, + { code: args.source }, + { + ownerKey: `${args.owner.entityType}:${args.owner.entityId}`, + signal: args.signal, + resolvePreparedFile(fileId) { + const input = inputs.get(fileId) + if (!input) + throw new Error('Document requested a file outside its authorized input manifest') + return input + }, + } + ) + } + assertKnownSizeWithinLimit(buffer.length, args.maxBytes, 'compiled document') + await storeCompiledDoc(args.owner, args.source, fmt.ext, fmt.contentType, buffer, cacheIdentity) + return { + buffer, + artifactKey: compiledArtifactKey(args.owner, args.source, fmt.ext, cacheIdentity), + contentType: fmt.contentType, + dependsOnReferencedFiles: args.inputs.length > 0, + } +} + // Template-clone scripts author against the simdoc Deck (pptx) / Doc (docx) // API. The prelude supplies input_path (staged workspace files) and // OUTPUT_PATH; the finalizer saves the expected variable (scripts never save @@ -698,6 +797,38 @@ function cachedFromAdmittedVersions( }) } +/** + * Whether a cached build may be served to `principal`: every file it read is still at the version it + * read, and the principal's gate admits each one. Every principal is checked, not only a classified + * one, because the build's contributor identities travel with the bytes and a delivery receipt + * rejects a superseded one. A build whose input changed or was deleted compiles again. + */ +async function cachedBuildIsCurrent( + cached: CompiledDocCacheEntry, + workspaceId: string, + principal: Principal +): Promise { + const contributors = cached.contributingFiles ?? [] + let resolution: ReferencedImageResolution + try { + resolution = await resolveReferencedFileIds( + workspaceId, + principal, + new Set(contributors.map((file) => file.fileId)) + ) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') return false + throw error + } + const records = resolution.images.map((image) => image.record) + if (!cachedFromAdmittedVersions(contributors, new Map(records.map((file) => [file.id, file])))) { + return false + } + const admitted = await admitWorkspaceFiles(principal, workspaceId, records, 'complete') + if (admitted.length !== records.length) throw new ContentRefusedError('workspace-file') + return true +} + async function compileDocInLegacySandbox( args: LegacyCompileArgs, fmt: E2BDocFormat @@ -714,29 +845,11 @@ async function compileDocInLegacySandbox( const cacheKey = sha256Hex(`.${fmt.ext}${args.source}${args.workspaceId}`) const cached = compiledDocCache.get(cacheKey) - let cacheReadable = true - if (cached && args.filePrincipal && mustClassifyContent(args.filePrincipal)) { - const { images } = await resolveReferencedFileIds( - args.workspaceId, - args.filePrincipal, - new Set(cached.contributingFiles?.map((file) => file.fileId)) - ) - const records = images.map((image) => image.record) - cacheReadable = cachedFromAdmittedVersions( - cached.contributingFiles ?? [], - new Map(records.map((file) => [file.id, file])) - ) - if (cacheReadable) { - const admitted = await admitWorkspaceFiles( - args.filePrincipal, - args.workspaceId, - records, - 'complete' - ) - if (admitted.length !== records.length) throw new ContentRefusedError('workspace-file') - } - } - if (cached && cacheReadable) { + if ( + cached && + (!args.filePrincipal || + (await cachedBuildIsCurrent(cached, args.workspaceId, args.filePrincipal))) + ) { return { buffer: cached.buffer, contentType: fmt.contentType, @@ -965,22 +1078,18 @@ export async function resolveServableDoc( } } -const MAX_COMPILED_DOC_CACHE = 10 interface CompiledDocCacheEntry { buffer: Buffer contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[] } -const compiledDocCache = new Map() +const compiledDocCache = new LRUCache({ max: 10 }) function compiledCacheSet( key: string, buffer: Buffer, contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] = [] ): void { - if (compiledDocCache.size >= MAX_COMPILED_DOC_CACHE) { - compiledDocCache.delete(compiledDocCache.keys().next().value as string) - } compiledDocCache.set(key, { buffer, ...(contributingFiles.length > 0 ? { contributingFiles } : {}), diff --git a/apps/sim/lib/uploads/documents/index.ts b/apps/sim/lib/uploads/documents/index.ts index 5641079904e..732f104e8cf 100644 --- a/apps/sim/lib/uploads/documents/index.ts +++ b/apps/sim/lib/uploads/documents/index.ts @@ -1,5 +1,6 @@ export { collectReferencedFileIds, + compileFileDocument, getDocumentSourceLanguage, getE2BDocFormat, isCompiledDocumentBuffer, diff --git a/apps/sim/lib/uploads/index.ts b/apps/sim/lib/uploads/index.ts index 3c026bae8f0..8974b287c06 100644 --- a/apps/sim/lib/uploads/index.ts +++ b/apps/sim/lib/uploads/index.ts @@ -5,5 +5,6 @@ export { } from '@/lib/uploads/config' export * as ChatFiles from '@/lib/uploads/contexts/chat' export * as CopilotFiles from '@/lib/uploads/contexts/copilot' -export { getFileMetadata, getServePathPrefix } from '@/lib/uploads/core/storage-client' +export { getServePathPrefix } from '@/lib/uploads/core/storage-client' export * as StorageService from '@/lib/uploads/core/storage-service' +export { getFileMetadata } from '@/lib/uploads/server/legacy-metadata' diff --git a/apps/sim/lib/uploads/server/delivery.ts b/apps/sim/lib/uploads/server/delivery.ts index 4358a128e14..a3ed7d483cd 100644 --- a/apps/sim/lib/uploads/server/delivery.ts +++ b/apps/sim/lib/uploads/server/delivery.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { ensureFileNameExtension } from '@/lib/uploads/utils/file' import { normalizeMimeType } from '@/lib/uploads/utils/mime' @@ -125,7 +126,7 @@ export const FILE_CACHE_CONTROL = { } as const /** Constructs identical security and filename headers for buffered and streamed representations. */ -function fileDeliveryHeaders(input: { +export function fileDeliveryHeaders(input: { filename: string contentType: string cacheControl: string @@ -144,6 +145,11 @@ function fileDeliveryHeaders(input: { return headers } +/** Hash only already-buffered representations; streamed downloads never need materializing. */ +export function bufferedRepresentationEtag(buffer: Buffer): string { + return `"${createHash('sha256').update(buffer).digest('base64url')}"` +} + /** Binary presenters share header assembly without coupling storage mechanics to a route framework. */ export function presentFileDelivery(input: { body: Buffer | ReadableStream @@ -167,3 +173,13 @@ export function presentFileDelivery(input: { headers, } } + +/** Immutable workspace caching requires a fixed storage object and no live referenced inputs. */ +export function workspaceFileCacheControl( + contentAddressed: boolean, + dependsOnReferencedFiles = false +): string { + return contentAddressed && !dependsOnReferencedFiles + ? FILE_CACHE_CONTROL.immutable + : FILE_CACHE_CONTROL.revalidate +} diff --git a/apps/sim/lib/uploads/server/embedded-image-refs.ts b/apps/sim/lib/uploads/server/embedded-image-refs.ts index 0fbb78ff65c..a5156c10db1 100644 --- a/apps/sim/lib/uploads/server/embedded-image-refs.ts +++ b/apps/sim/lib/uploads/server/embedded-image-refs.ts @@ -11,6 +11,7 @@ import { extractEmbeddedFileRef, storedFileId, } from '@/lib/uploads/utils/embedded-image-ref' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' /** Hard cap on embedded images collected for a bulk export bundle. */ export const MAX_EMBEDDED_IMAGES = 50 @@ -43,33 +44,48 @@ function childrenOf(token: Token): Token[] { * Links are excluded for the same reason: a link is navigated to, not displayed, so it is neither an * exportable asset nor something a document's public share should cascade to. */ -export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: string[] } { +export function extractEmbeddedFileRefs( + content: string, + owner?: EditableFileOwner +): { keys: string[]; ids: string[] } { const keys = new Set() const ids = new Set() - visitEmbeddedFileRefs(content, (ref) => { - if ('key' in ref) keys.add(ref.key) - else ids.add(ref.fileId) - return keys.size + ids.size >= MAX_EMBEDDED_IMAGES - }) + visitEmbeddedFileRefs( + content, + (ref) => { + if ('key' in ref) keys.add(ref.key) + else ids.add(ref.fileId) + return keys.size + ids.size >= MAX_EMBEDDED_IMAGES + }, + owner + ) return { keys: [...keys], ids: [...ids] } } /** Matches one stored image reference without imposing a bulk export's asset-count limit. */ -export function hasEmbeddedFileRef(content: string, target: NonNullable): boolean { - return visitEmbeddedFileRefs(content, (ref) => - 'fileId' in target - ? 'fileId' in ref && storedFileId(ref.fileId) === target.fileId - : 'key' in ref && ref.key === target.key +export function hasEmbeddedFileRef( + content: string, + target: NonNullable, + owner?: EditableFileOwner +): boolean { + return visitEmbeddedFileRefs( + content, + (ref) => + 'fileId' in target + ? 'fileId' in ref && storedFileId(ref.fileId) === target.fileId + : 'key' in ref && ref.key === target.key, + owner ) } /** Stops at the first accepted reference; callers bound document bytes before parsing. */ function visitEmbeddedFileRefs( content: string, - visit: (ref: NonNullable) => boolean + visit: (ref: NonNullable) => boolean, + owner?: EditableFileOwner ): boolean { const record = (src: string) => { - const ref = extractEmbeddedFileRef(src) + const ref = extractEmbeddedFileRef(src, owner) return ref !== null && visit(ref) } diff --git a/apps/sim/lib/uploads/server/image-derivative.ts b/apps/sim/lib/uploads/server/image-derivative.ts index b17559fbefc..82367c5f97d 100644 --- a/apps/sim/lib/uploads/server/image-derivative.ts +++ b/apps/sim/lib/uploads/server/image-derivative.ts @@ -4,17 +4,24 @@ import { getErrorMessage } from '@sim/utils/errors' import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' import { isHevcHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' const logger = createLogger('ImageDerivative') +interface ImageDerivativeOptions { + owner: EditableFileOwner +} + /** * Keyed by the source's storage key rather than a hash of its bytes. Workspace keys * are regenerated on every content replacement, so the key is already a content * version β€” using it avoids streaming the whole original just to hash it. */ -function derivativeKey(storageKey: string): string { +function derivativeKey(storageKey: string, owner?: EditableFileOwner): string { const hash = createHash('sha256').update(storageKey, 'utf-8').digest('hex') - return `image-derivative/${hash}.jpg` + return owner?.entityType === 'project' + ? `project/${owner.entityId}/image-derivative/${hash}.jpg` + : `image-derivative/${hash}.jpg` } /** @@ -27,11 +34,14 @@ function derivativeKey(storageKey: string): string { * in that band into a miss, re-transcoding the original on each preview, which costs * more than serving the cached copy would have. */ -async function loadDerivative(storageKey: string): Promise { +async function loadDerivative( + storageKey: string, + options?: ImageDerivativeOptions +): Promise { try { return await downloadFile({ - key: derivativeKey(storageKey), - context: 'copilot', + key: derivativeKey(storageKey, options?.owner), + context: options?.owner.entityType === 'project' ? 'project' : 'copilot', maxBytes: MAX_BUFFERED_TRANSFER_BYTES, }) } catch { @@ -47,14 +57,19 @@ async function loadDerivative(storageKey: string): Promise { * read transcodes again. Failing the request would turn a cache problem into a broken * image for bytes we have already rendered successfully. */ -async function storeDerivative(storageKey: string, jpeg: Buffer): Promise { +async function storeDerivative( + storageKey: string, + jpeg: Buffer, + options?: ImageDerivativeOptions +): Promise { + const key = derivativeKey(storageKey, options?.owner) try { await uploadFile({ file: jpeg, fileName: 'derivative.jpg', contentType: 'image/jpeg', - context: 'copilot', - customKey: derivativeKey(storageKey), + context: options?.owner.entityType === 'project' ? 'project' : 'copilot', + customKey: key, preserveKey: true, }) } catch (error) { @@ -81,16 +96,17 @@ async function storeDerivative(storageKey: string, jpeg: Buffer): Promise */ export async function resolveServableImageBytes( buffer: Buffer, - storageKey: string + storageKey: string, + options?: ImageDerivativeOptions ): Promise<{ buffer: Buffer; contentType: string } | null> { if (!isHevcHeifContainer(buffer)) return null - const cached = await loadDerivative(storageKey) + const cached = await loadDerivative(storageKey, options) if (cached) return { buffer: cached, contentType: 'image/jpeg' } const jpeg = await transcodeHeicToJpeg(buffer) if (!jpeg) return null - await storeDerivative(storageKey, jpeg) + await storeDerivative(storageKey, jpeg, options) return { buffer: jpeg, contentType: 'image/jpeg' } } diff --git a/apps/sim/lib/uploads/core/storage-client.test.ts b/apps/sim/lib/uploads/server/legacy-metadata.test.ts similarity index 80% rename from apps/sim/lib/uploads/core/storage-client.test.ts rename to apps/sim/lib/uploads/server/legacy-metadata.test.ts index 25ead64beb3..6e1387a9d9f 100644 --- a/apps/sim/lib/uploads/core/storage-client.test.ts +++ b/apps/sim/lib/uploads/server/legacy-metadata.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/uploads/providers/s3/client', () => ({ vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) -import { getFileMetadata } from '@/lib/uploads/core/storage-client' +import { getFileMetadata } from '@/lib/uploads/server/legacy-metadata' setUploadsConfig({ USE_S3_STORAGE: true, @@ -49,6 +49,20 @@ describe('getFileMetadata', () => { await expect(getFileMetadata('workspace/ws/key.md')).rejects.toThrow('AccessDenied') }) + it.each(['general', 'project'])( + 'does not grant a legacy uploader read for %s files', + async (context) => { + mockGetFileMetadataByKey.mockResolvedValue({ + userId: 'uploader', + context, + workspaceId: null, + originalName: 'shared.md', + uploadedAt: new Date(), + }) + expect(await getFileMetadata(`${context}/shared.md`)).not.toHaveProperty('userId') + } + ) + it('prefers the database record when one exists', async () => { mockGetFileMetadataByKey.mockResolvedValue({ userId: 'user-1', diff --git a/apps/sim/lib/uploads/server/legacy-metadata.ts b/apps/sim/lib/uploads/server/legacy-metadata.ts new file mode 100644 index 00000000000..e9d6a62e917 --- /dev/null +++ b/apps/sim/lib/uploads/server/legacy-metadata.ts @@ -0,0 +1,32 @@ +import { getStorageObjectMetadata } from '@/lib/uploads/core/storage-client' +import type { StorageConfig } from '@/lib/uploads/shared/types' + +/** + * Compatibility projection for legacy key-based authorization. Shared Project files require + * their Principal-aware operation; their creator must never become a legacy user grant. + * Owner-aware operations consume canonical metadata through resolveFileOwner instead. + */ +export async function getFileMetadata( + key: string, + customConfig?: StorageConfig +): Promise> { + const { getFileMetadataByKey } = await import('@/lib/uploads/server/metadata') + const metadataRecord = await getFileMetadataByKey(key) + + if (metadataRecord) { + return { + ...(metadataRecord.projectId == null && + metadataRecord.context !== 'project' && + metadataRecord.context !== 'general' && + metadataRecord.userId + ? { userId: metadataRecord.userId } + : {}), + workspaceId: metadataRecord.workspaceId || '', + originalName: metadataRecord.originalName, + uploadedAt: metadataRecord.uploadedAt.toISOString(), + purpose: metadataRecord.context, + } + } + + return getStorageObjectMetadata(key, customConfig) +} diff --git a/apps/sim/lib/uploads/server/markdown-export.ts b/apps/sim/lib/uploads/server/markdown-export.ts index f60a205b8cc..207253f79f8 100644 --- a/apps/sim/lib/uploads/server/markdown-export.ts +++ b/apps/sim/lib/uploads/server/markdown-export.ts @@ -10,10 +10,14 @@ import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' import { unified } from 'unified' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { StorageContext } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' +import { bufferZipWithinLimit } from '@/lib/uploads/server/zip' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' import { formatFileSize } from '@/lib/uploads/utils/file' +import { safeZipLeafName } from '@/lib/uploads/zip-entry-path' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' const logger = createLogger('MarkdownExport') @@ -21,7 +25,7 @@ const logger = createLogger('MarkdownExport') export const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 /** Larger documents remain available as exact Markdown without allocating a syntax tree. */ export const MAX_EXPORT_MARKDOWN_PARSE_BYTES = 10 * 1024 * 1024 -const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 +export const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const markdownProcessor = unified() .use(remarkParse) .use(remarkGfm) @@ -29,7 +33,11 @@ const markdownProcessor = unified() const markdownLexer = new Marked() /** Source ranges keep unrelated links, definitions, code, and whitespace byte-for-byte intact. */ -function rewriteImageSources(source: string, filenames: ReadonlyMap): string { +function rewriteImageSources( + source: string, + filenames: ReadonlyMap, + owner?: EditableFileOwner +): string { const { frontmatter, body: content } = splitFrontmatter(source) const root = markdownProcessor.parse(content) const definitions = new Map() @@ -45,8 +53,8 @@ function rewriteImageSources(source: string, filenames: ReadonlyMap { - const ref = extractEmbeddedFileRef(src) - const filename = ref && 'fileId' in ref ? filenames.get(ref.fileId) : undefined + const ref = extractEmbeddedFileRef(src, owner) + const filename = ref ? filenames.get('fileId' in ref ? ref.fileId : ref.key) : undefined return filename === undefined ? null : `./assets/${encodeURIComponent(filename)}` } const replacements: Array<{ start: number; end: number; value: string }> = [] @@ -160,6 +168,8 @@ export interface MarkdownExportAsset { context: StorageContext originalName: string size: number + /** Prepared authorized bytes let a compound export fence every asset after external IO. */ + buffer?: Buffer } export interface MarkdownExportResult { @@ -180,10 +190,11 @@ export class MarkdownExportSizeError extends Error { } function safeFilename(name: string): string { - return path + const filename = path .basename(name) .replace(/["\\]/g, '_') .replace(/[\r\n\t]/g, '') + return !filename || filename === '.' || filename === '..' ? safeZipLeafName(name) : filename } function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { @@ -203,10 +214,12 @@ export async function createMarkdownExport({ content, fileName, assets, + owner, }: { content: Buffer fileName: string assets: readonly MarkdownExportAsset[] + owner?: EditableFileOwner }): Promise { const plainMarkdown: MarkdownExportResult = { buffer: content, @@ -226,11 +239,14 @@ export async function createMarkdownExport({ if (overflowBytes !== undefined) return null let buffer: Buffer try { - buffer = await downloadFile({ - key: asset.key, - context: asset.context, - maxBytes: MAX_EXPORT_ASSET_BYTES, - }) + buffer = + asset.buffer ?? + (await downloadFile({ + key: asset.key, + context: asset.context, + maxBytes: MAX_EXPORT_ASSET_BYTES, + })) + assertKnownSizeWithinLimit(buffer.length, MAX_EXPORT_ASSET_BYTES, 'Markdown export asset') } catch (error) { logger.warn('Failed to fetch asset for export', { imageId: asset.imageId, @@ -265,16 +281,27 @@ export async function createMarkdownExport({ const markdown = rewriteImageSources( content.toString('utf-8'), - new Map([...assetMap].map(([imageId, asset]) => [imageId, asset.filename])) + new Map([...assetMap].map(([imageId, asset]) => [imageId, asset.filename])), + owner ) + const exportedBytes = retainedBytes - content.length + Buffer.byteLength(markdown, 'utf-8') + if (exportedBytes > MAX_EXPORT_TOTAL_BYTES) throw new MarkdownExportSizeError(exportedBytes) const zip = new JSZip() zip.file(safeFilename(fileName), markdown) const assetsFolder = zip.folder('assets')! for (const { filename, buffer } of assetMap.values()) assetsFolder.file(filename, buffer) + let buffer: Buffer + try { + buffer = await bufferZipWithinLimit(zip, MAX_EXPORT_TOTAL_BYTES) + } catch (error) { + if (isPayloadSizeLimitError(error)) + throw new MarkdownExportSizeError(error.observedBytes ?? MAX_EXPORT_TOTAL_BYTES + 1) + throw error + } return { - buffer: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }), + buffer, fileName: safeFilename(`${fileName.replace(/\.[^.]+$/, '')}.zip`), contentType: 'application/zip', format: 'zip', diff --git a/apps/sim/lib/uploads/server/zip.ts b/apps/sim/lib/uploads/server/zip.ts new file mode 100644 index 00000000000..5b58f1ec46a --- /dev/null +++ b/apps/sim/lib/uploads/server/zip.ts @@ -0,0 +1,10 @@ +import type JSZip from 'jszip' +import { readNodeStreamToBufferWithLimit } from '@/lib/core/utils/stream-limits' + +/** Bounds emitted ZIP bytes while compression runs, before allocating the final output buffer. */ +export function bufferZipWithinLimit(zip: JSZip, maxBytes: number): Promise { + return readNodeStreamToBufferWithLimit( + zip.generateNodeStream({ streamFiles: true, compression: 'DEFLATE' }), + { maxBytes, label: 'file archive' } + ) +} diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts index f49a97b1269..4df92926927 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -1,3 +1,6 @@ +import { parseSimFileReference } from '@/lib/uploads/utils/file-reference' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + /** * The grammar of a markup-embedded workspace image reference: how one `src` maps to the workspace * file it points at ({@link extractEmbeddedFileRef}), and how to find the `src` values in a raw HTML @@ -25,21 +28,37 @@ export function storedFileId(spelledId: string): string { } } +/** A rejected private reference must not fall back to its credential-bearing URL. */ +export type EmbeddedFileResolution = + | { kind: 'file'; reference: NonNullable } + | { kind: 'external' } + | { kind: 'rejected' } + /** - * Parse a single embed `src` into the workspace file it references, normalizing the spellings the - * editor and file agent produce: `/api/files/serve/` (incl. `s3/`/`blob/`/`gcs/` prefixes), `/api/files/view/`, - * and the in-app path `/workspace//files/`. Returns null for absolute, `data:`, or non-workspace - * URLs (e.g. public `profile-pictures/` assets), which render as-is. - * - * A key is percent-decoded β€” it is matched against stored keys. An id is returned exactly as it is - * spelled in the `src`, because the export bundler rewrites embeds by searching the document for - * that spelling; handing it a decoded id it cannot find would bundle an asset and leave the markdown - * pointing at the API URL, which renders as a broken image offline. + * Classifies embeds using one grammar for inline authorization and display. Absolute internal + * references require a caller-supplied trusted origin; without it they remain rejected for SSR. */ -export function extractEmbeddedFileRef(src: string): EmbeddedFileRef { +export function resolveEmbeddedFileRef( + src: string, + owner?: EditableFileOwner, + trustedOrigin?: string +): EmbeddedFileResolution { try { - const parsed = new URL(src, 'http://placeholder') - if (parsed.origin !== 'http://placeholder') return null + const value = src.trim() + if (value.startsWith('sim:file/')) { + if (!owner) return { kind: 'rejected' } + const reference = parseSimFileReference(value.slice('sim:file/'.length), owner) + return reference?.owner?.entityType === owner.entityType && + reference.owner.entityId === owner.entityId + ? { kind: 'file', reference: { fileId: reference.fileId } } + : { kind: 'rejected' } + } + const absolute = /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//') + const origin = trustedOrigin ? new URL(trustedOrigin).origin : undefined + const parsed = new URL(value, origin ?? 'http://placeholder') + if (absolute && origin && parsed.origin !== origin) return { kind: 'external' } + const resolved = (reference: NonNullable): EmbeddedFileResolution => + absolute && !origin ? { kind: 'rejected' } : { kind: 'file', reference } const segs = parsed.pathname.split('/') if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') { let keySegs = segs.slice(4) @@ -47,22 +66,57 @@ export function extractEmbeddedFileRef(src: string): EmbeddedFileRef { keySegs = keySegs.slice(1) } const raw = keySegs.join('/') - if (!raw) return null + if (!raw) return { kind: 'rejected' } const key = decodeURIComponent(raw) - return key.startsWith('workspace/') ? { key } : null + if (!key.startsWith('workspace/') && !key.startsWith('project/')) return { kind: 'external' } + return key.startsWith(owner ? `${owner.entityType}/${owner.entityId}/` : 'workspace/') + ? resolved({ key }) + : { kind: 'rejected' } + } + if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'view') { + return segs[4] ? resolved({ fileId: segs[4] }) : { kind: 'rejected' } } - if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'view' && segs[4]) { - return { fileId: segs[4] } + if (segs[1] === 'api' && segs[2] === 'projects' && segs[4] === 'files') { + return owner?.entityType === 'project' && + decodeURIComponent(segs[3]) === owner.entityId && + segs[5] && + segs[6] === 'content' && + segs.length === 7 + ? resolved({ fileId: segs[5] }) + : { kind: 'rejected' } } - if (segs[1] === 'workspace' && segs[3] === 'files' && segs[4]) { - return { fileId: segs[4] } + if (segs[1] === 'projects' && segs[3] === 'files') { + return owner?.entityType === 'project' && + decodeURIComponent(segs[2]) === owner.entityId && + segs[4] && + segs.length === 5 + ? resolved({ fileId: segs[4] }) + : { kind: 'rejected' } } - return null + if (segs[1] === 'workspace' && segs[3] === 'files') { + if ( + !segs[4] || + (owner && + (owner.entityType !== 'workspace' || decodeURIComponent(segs[2]) !== owner.entityId)) + ) + return { kind: 'rejected' } + return resolved({ fileId: segs[4] }) + } + return { kind: 'external' } } catch { - return null + return { kind: 'rejected' } } } +/** + * Returns stored-key or document-spelled ID references for existing scanners and exporters. + * Display code uses the full classification so a rejected internal reference cannot pass through. + */ +export function extractEmbeddedFileRef(src: string, owner?: EditableFileOwner): EmbeddedFileRef { + const result = resolveEmbeddedFileRef(src, owner) + return result.kind === 'file' ? result.reference : null +} + /** * Matches `` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per * the HTML spec β€” the browser's own clipboard serialization always quotes it, but other producers diff --git a/apps/sim/lib/uploads/utils/file-reference.ts b/apps/sim/lib/uploads/utils/file-reference.ts new file mode 100644 index 00000000000..784b8e605ed --- /dev/null +++ b/apps/sim/lib/uploads/utils/file-reference.ts @@ -0,0 +1,29 @@ +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +/** Parses the ID, optional single owner, and fragment of a sim:file resource reference. */ +export function parseSimFileReference(reference: string, defaultOwner?: EditableFileOwner) { + const match = /^([^?#]+)(?:\?([^#]*))?(#.*)?$/.exec(reference) + if (!match) return null + let fileId: string + try { + fileId = decodeURIComponent(match[1]) + } catch { + return null + } + if (!/^[A-Za-z0-9_-]+$/.test(fileId)) return null + let owner = defaultOwner + if (match[2] !== undefined) { + const entries = [...new URLSearchParams(match[2])] + const entry = entries[0] + if (entries.length !== 1 || !entry) return null + const [entityType, entityId] = entry + if ( + (entityType !== 'workspace' && entityType !== 'project') || + !/^[A-Za-z0-9_-]+$/.test(entityId) + ) { + return null + } + owner = { entityType, entityId } + } + return { fileId, owner, fragment: match[3] ?? '' } +} diff --git a/apps/sim/lib/uploads/utils/file.server.test.ts b/apps/sim/lib/uploads/utils/file.server.test.ts index 6a7d21de758..13e4767f03b 100644 --- a/apps/sim/lib/uploads/utils/file.server.test.ts +++ b/apps/sim/lib/uploads/utils/file.server.test.ts @@ -222,6 +222,31 @@ describe('servable page provenance', () => { buffer: Buffer.from('rendered image'), contentType: 'text/html', contributingFiles: [contributor], + dependsOnReferencedFiles: true, }) }) + + it('never reports a page render as derived from its source alone, even with no inlined images', async () => { + const workspaceId = '2f1d8c3e-5b6a-4c7d-8e9f-0a1b2c3d4e5f' + mockParseWorkspaceFileKey.mockReturnValue(workspaceId) + mockDownloadFile.mockResolvedValue(Buffer.from('---\ntitle: Example\n---\nPage body')) + mockRenderPage.mockResolvedValue({ html: 'styled page', contributingFiles: [] }) + + const rendered = await downloadServableFileFromStorage( + { + id: 'page-file', + name: 'Page', + key: `workspace/${workspaceId}/page`, + url: '', + type: 'text/x-sim-page', + size: 100, + context: 'workspace', + }, + 'request', + createLogger('test'), + { maxBytes: 1024 } + ) + + expect(rendered.dependsOnReferencedFiles).toBe(true) + }) }) diff --git a/apps/sim/lib/uploads/utils/file.server.ts b/apps/sim/lib/uploads/utils/file.server.ts index 96dd5c47782..0f5cb1180e6 100644 --- a/apps/sim/lib/uploads/utils/file.server.ts +++ b/apps/sim/lib/uploads/utils/file.server.ts @@ -410,6 +410,7 @@ export async function downloadFileFromStorage( * actually attach/upload, plus the content type that matches those bytes. */ export interface ServableFile { + dependsOnReferencedFiles?: boolean artifactKey?: string buffer: Buffer contentType: string @@ -473,6 +474,8 @@ export async function downloadServableFileFromStorage( buffer: rendered, contentType: 'text/html', contributingFiles: page.contributingFiles, + /** Even with no inlined images, the render bakes in the deployed stylesheet and shell. */ + dependsOnReferencedFiles: true, } } } diff --git a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts index cc10a685eaf..3bd9f212779 100644 --- a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts +++ b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts @@ -40,6 +40,7 @@ import { import { runCli } from '@/lib/mothership/agent-cli/run-cli' import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport' +import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance' import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' import { updateWorkspaceFileContent, @@ -145,6 +146,12 @@ describe('chat-delegated file version history', () => { endpoint: ORIGIN, invocation, ...(registry ? { registry } : {}), + files: createWorkbenchFileProvenance({ + userId, + workspaceId: fixture.workspaceId, + sessionKey: invocation.chatId, + resolvedSecretTraceRegistry: registry, + }), resources: [], observeReads: true, }) diff --git a/apps/sim/lib/workspace-files/api/archive-presenter.test.ts b/apps/sim/lib/workspace-files/api/archive-presenter.test.ts new file mode 100644 index 00000000000..7c526896b41 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/archive-presenter.test.ts @@ -0,0 +1,115 @@ +/** @vitest-environment node */ +import { Readable } from 'node:stream' +import { flushMacrotask } from '@sim/testing/helpers' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' +import JSZip from 'jszip' +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' + +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) + +import { presentWorkspaceFileArchive } from '@/lib/workspace-files/api/archive-presenter' + +function plan() { + const filesToZip: WorkspaceFileRecord[] = Array.from({ length: 3 }, (_, index) => ({ + id: `file-${index}`, + workspaceId: 'workspace', + name: `${index}.txt`, + key: `workspace/${index}`, + path: `/files/${index}`, + size: 131072, + type: 'text/plain', + uploadedBy: 'creator', + uploadedAt: new Date(0), + updatedAt: new Date(0), + })) + return { + filesToZip, + excludedFileIds: [], + folderPaths: new Map(), + renderedDocuments: new Map(), + declaredBytes: 393216, + } +} + +describe('file archive resource lifetime', () => { + it('keeps only one storage stream open while preserving every entry', async () => { + let active = 0 + let peak = 0 + storageServiceMockFns.mockDownloadFileStream.mockImplementation(async () => { + active++ + peak = Math.max(peak, active) + const source = Readable.from( + (async function* () { + yield Buffer.alloc(65536, 'a') + await flushMacrotask() + yield Buffer.alloc(65536, 'b') + })() + ) + let released = false + const release = () => { + if (!released) { + released = true + active-- + } + } + source.once('end', release) + source.once('close', release) + return source + }) + const result = presentWorkspaceFileArchive(plan()) + const zip = await JSZip.loadAsync(await new Response(result.body).arrayBuffer()) + for (const name of ['0.txt', '1.txt', '2.txt']) { + expect(await zip.file(name)?.async('string')).toBe('a'.repeat(65536) + 'b'.repeat(65536)) + } + expect(peak).toBe(1) + expect(active).toBe(0) + }) + + it('rejects partial storage failure and closes the input', async () => { + const source = Readable.from( + (async function* () { + yield Buffer.from('partial') + throw new Error('Storage unavailable') + })() + ) + storageServiceMockFns.mockDownloadFileStream.mockResolvedValue(source) + const result = presentWorkspaceFileArchive(plan()) + await expect(new Response(result.body).arrayBuffer()).rejects.toThrow('Storage unavailable') + expect(source.destroyed).toBe(true) + }) + + it('closes a storage stream acquired after the client cancels', async () => { + const acquiring = createDeferred() + const acquired = createDeferred() + const source = new Readable({ read() {} }) + storageServiceMockFns.mockDownloadFileStream.mockImplementationOnce(() => { + acquiring.resolve() + return acquired.promise + }) + const reader = presentWorkspaceFileArchive(plan()).body.getReader() + try { + await acquiring.promise + await reader.cancel() + acquired.resolve(source) + await vi.waitFor(() => expect(source.destroyed).toBe(true)) + } finally { + acquired.resolve(source) + source.destroy() + } + }) + + it('closes the active source when the client cancels', async () => { + const source = new Readable({ + read() { + this.push(Buffer.alloc(65536)) + }, + }) + storageServiceMockFns.mockDownloadFileStream.mockResolvedValue(source) + const reader = presentWorkspaceFileArchive(plan()).body.getReader() + await reader.read() + await reader.cancel() + await vi.waitFor(() => expect(source.destroyed).toBe(true)) + }) +}) diff --git a/apps/sim/lib/workspace-files/api/archive-presenter.ts b/apps/sim/lib/workspace-files/api/archive-presenter.ts new file mode 100644 index 00000000000..ce628926fa4 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/archive-presenter.ts @@ -0,0 +1,95 @@ +import { once } from 'node:events' +import { addAbortSignal, Readable } from 'node:stream' +import { finished } from 'node:stream/promises' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { ZipArchive } from 'archiver' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { FILE_CACHE_CONTROL, fileDeliveryHeaders } from '@/lib/uploads/server/delivery' +import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' +import type { DownloadWorkspaceFileItemsResult } from '@/lib/workspace-files/application/download-workspace-file-items' + +const logger = createLogger('FileArchiveDelivery') + +/** Stream an authorized archive plan, opening at most the current entry rather than buffering files. */ +export function presentWorkspaceFileArchive({ + filesToZip, + folderPaths, + renderedDocuments, + excludedFileIds = [], +}: DownloadWorkspaceFileItemsResult) { + const report = excludedFileIds.length > 0 ? { partial: true, excludedFileIds } : undefined + const entryPaths = buildZipEntryPaths([ + ...filesToZip.map((file) => ({ + name: file.name, + folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + contentType: file.type, + })), + ...(report ? [{ name: 'sim-download-report.json', contentType: 'application/json' }] : []), + ]) + const archive = new ZipArchive({ store: true }) + archive.on('warning', (error: Error) => logger.warn('Archive warning', { error })) + const closed = new AbortController() + let activeInput: Readable | undefined + archive.once('close', () => { + closed.abort() + activeInput?.destroy() + }) + async function appendEntries() { + for (const [index, file] of filesToZip.entries()) { + closed.signal.throwIfAborted() + const rendered = renderedDocuments.get(file.id) + const input = + rendered ?? + Readable.from( + (async function* () { + const source = addAbortSignal( + closed.signal, + await downloadFileStream({ + key: file.key, + context: file.storageContext ?? 'workspace', + }) + ) + try { + yield* source + } finally { + source.destroy() + } + })(), + { objectMode: false } + ) + activeInput = input instanceof Readable ? input : undefined + const consumed = once(archive, 'entry', { signal: closed.signal }) + const sourceFinished = activeInput + ? finished(activeInput, { readable: true, writable: false, cleanup: true }) + : undefined + try { + archive.append(input, { name: entryPaths[index] }) + await Promise.all([consumed, sourceFinished]) + } finally { + activeInput?.destroy() + activeInput = undefined + } + } + if (report) { + archive.append(JSON.stringify(report, null, 2), { name: entryPaths[filesToZip.length] }) + } + await archive.finalize() + } + appendEntries().catch((error: unknown) => { + if (toError(error).name === 'AbortError') return + logger.error('Failed to build file archive', { error }) + archive.destroy(toError(error)) + }) + return { + body: nodeReadableToWebStream(archive), + contentType: 'application/zip', + headers: fileDeliveryHeaders({ + filename: 'workspace-files.zip', + contentType: 'application/zip', + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), + } +} diff --git a/apps/sim/lib/workspace-files/api/copy-presenter.ts b/apps/sim/lib/workspace-files/api/copy-presenter.ts new file mode 100644 index 00000000000..c15e5da753b --- /dev/null +++ b/apps/sim/lib/workspace-files/api/copy-presenter.ts @@ -0,0 +1,27 @@ +import type { CopyFileItemsResponse } from '@/lib/api/contracts/file-copy' +import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision' +import type { CopiedFileItems } from '@/lib/workspace-files/copy' + +/** Both copy surfaces expose new canonical identities without object-storage addresses. */ +export function presentCopiedFileItems(result: CopiedFileItems): CopyFileItemsResponse { + return { + files: result.files.map(({ key, path, ...file }) => { + const revision = workspaceFileRevision(file) + if (revision === null) throw new Error('Copied file is missing its content revision') + return { + ...file, + uploadedAt: file.uploadedAt.toISOString(), + updatedAt: file.updatedAt.toISOString(), + contentUpdatedAt: file.contentUpdatedAt?.toISOString() ?? null, + deletedAt: file.deletedAt?.toISOString() ?? null, + revision, + } + }), + folders: result.folders.map((folder) => ({ + ...folder, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + deletedAt: folder.deletedAt?.toISOString() ?? null, + })), + } +} diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts index 8943f1051c3..144afebaf0e 100644 --- a/apps/sim/lib/workspace-files/api/index.ts +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -6,4 +6,5 @@ export { internalWorkspaceFileServeAuth, v2FileErrorPolicies, } from '@/lib/workspace-files/api/route-policies' -export { toFileVersion } from '@/lib/workspace-files/api/version-presenters' +export { presentWorkspaceFileArchive } from './archive-presenter' +export { toFileVersion } from './version-presenters' diff --git a/apps/sim/lib/workspace-files/application/copy-authorization.ts b/apps/sim/lib/workspace-files/application/copy-authorization.ts new file mode 100644 index 00000000000..a7a185bb07b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts @@ -0,0 +1,253 @@ +import type { Principal, ResourceFileCopyScope } from '@sim/auth/principal' +import { workspace } from '@sim/db/schema' +import { compareStrings } from '@sim/utils/string' +import { and, asc, inArray, isNull } from 'drizzle-orm' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' +import { + isResourceFileCopyScope, + requireResourceDelegation, +} from '@/lib/core/application/resource-delegation' +import { + authorizeWorkspaceOperation, + PrincipalKindAuthorizationError, + requireCurrentHumanRole, +} from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { loadProjectAccess } from '@/lib/projects/application/authorization' +import { resolveCopilotProjectScope } from '@/lib/projects/application/discovery' +import { + type ProjectFileAuthorizationContext, + requireCurrentCopilotProjectInvocation, + requireProjectFileOwnerCapabilities, + requireProjectFileOwnerRole, +} from '@/lib/projects/files/application/authorization' +import { lockProject, lockProjectBackfillWrites } from '@/lib/projects/membership' +import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' +import { + FILE_COPY_DELEGATION_TTL_MS, + fileCopyOperation, +} from '@/lib/workspace-files/application/copy-operation' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + requireCurrentFileSubject, + requireFileSubject, +} from '@/lib/workspace-files/application/subject' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' + +export type CopyFileItemsInput = Omit + +type CopyPrincipal = + | Extract + | Extract + +type ProjectAccess = Awaited> + +type FileCopyOwnerContext = + | ProjectFileAuthorizationContext + | { + owner: { entityType: 'workspace'; entityId: string } + workspaceId: string + organizationId: string | null + billedAccountUserId: string + } + +export interface FileCopyAuthorizationContext { + source: FileCopyOwnerContext + destination: FileCopyOwnerContext +} + +interface CopyOwnerPolicyInput { + tx: DbTransaction + principal: CopyPrincipal + userId: string + owner: ResourceFileCopyScope['source']['owner'] + mode: 'read' | 'write' + projects: ReadonlyMap + workspaces: ReadonlyMap +} + +type CopyOwnerPolicy = (input: CopyOwnerPolicyInput) => Promise + +const COPY_OWNER_POLICIES: FileOwnerAdapters = { + async project({ tx, principal, owner, mode, projects }) { + const access = projects.get(owner.entityId) + if (!access) throw new OrchestrationError('not_found', 'Project not found') + const context = await requireProjectFileOwnerRole(tx, principal, access, mode) + await requireProjectFileOwnerCapabilities(tx, principal, access, fileCopyOperation.capability) + return context + }, + async workspace({ tx, principal, userId, owner, mode, workspaces }) { + const row = workspaces.get(owner.entityId) + if (!row) throw new OrchestrationError('not_found', 'Workspace not found') + const context = { + workspaceId: row.id, + workspaceOrganizationId: row.organizationId, + allowPersonalApiKeys: row.allowPersonalApiKeys, + } + // Project access already holds membership and permission SHARE locks; upgrading can deadlock other Projects. + if (principal.kind === 'resource_delegated') { + await requireCurrentHumanRole(userId, context, mode, { executor: tx }) + // permission-group-enforced: files.use β€” copy delegation retains the subject's workspace file policy. + await assertWorkspaceCapability( + userId, + row.id, + fileCopyOperation.capability, + row.organizationId, + tx + ) + } else { + await authorizeWorkspaceOperation( + principal, + mode === 'read' ? fileOperations.list : fileOperations.create, + context, + { executor: tx } + ) + } + return { + owner: { entityType: 'workspace', entityId: row.id }, + workspaceId: row.id, + organizationId: row.organizationId, + billedAccountUserId: row.billedAccountUserId, + } + }, +} + +function requireCopyPrincipal( + principal: Principal, + input: CopyFileItemsInput +): asserts principal is CopyPrincipal { + if (!fileCopyOperation.principalKinds.some((kind) => kind === principal.kind)) { + throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) + } + const scope = { kind: 'file_copy', ...input } + if (!isResourceFileCopyScope(scope)) { + throw new OrchestrationError( + 'validation', + 'Copy requires bounded source and destination owners' + ) + } + requireOAuthOperationScope(principal, fileCopyOperation) + if (principal.kind === 'resource_delegated') { + requireResourceDelegation(principal, { + audience: fileCopyOperation.delegationAudience, + services: fileCopyOperation.delegatedServices, + scope, + maxTtlMs: FILE_COPY_DELEGATION_TTL_MS, + }) + } +} + +/** Authorizes both owners under canonical locks; selected rows remain the copy manager's responsibility. */ +export async function createFileCopyAuthorizer( + principal: Principal, + input: CopyFileItemsInput +): Promise<(tx: DbTransaction) => Promise> { + requireCopyPrincipal(principal, input) + await requireProjectApiEnabled() + // actorless-unsupported: files.copy rejects executors and workspace keys; both owner policies require the acting human. + const userId = await requireFileSubject(principal) + const invocationScope = + principal.kind === 'resource_delegated' + ? await resolveCopilotProjectScope(principal) + : undefined + + return async (tx) => { + requireCopyPrincipal(principal, input) + const actingPrincipal = principal + const owners = [input.source.owner, input.destination.owner] + const workspaceIds = [ + ...new Set( + owners.filter((owner) => owner.entityType === 'workspace').map((owner) => owner.entityId) + ), + ].sort(compareStrings) + await lockProjectBackfillWrites(tx, workspaceIds) + const memberships = workspaceIds.length + ? await tx + .select({ workspaceId: workspace.id, projectId: workspace.projectId }) + .from(workspace) + .where(inArray(workspace.id, workspaceIds)) + : [] + const parents = new Map(memberships.map((row) => [row.workspaceId, row.projectId])) + if (workspaceIds.some((workspaceId) => !parents.has(workspaceId))) { + throw new OrchestrationError('not_found', 'Workspace not found in a Project') + } + const projectIds = [ + ...new Set([ + ...owners.filter((owner) => owner.entityType === 'project').map((owner) => owner.entityId), + ...parents.values(), + ]), + ].sort(compareStrings) + for (const projectId of projectIds) await lockProject(tx, projectId) + const currentMemberships = workspaceIds.length + ? await tx + .select({ workspaceId: workspace.id, projectId: workspace.projectId }) + .from(workspace) + .where(inArray(workspace.id, workspaceIds)) + : [] + if ( + currentMemberships.length !== memberships.length || + currentMemberships.some((row) => parents.get(row.workspaceId) !== row.projectId) + ) { + throw new OrchestrationError('conflict', 'Project membership changed; retry the copy') + } + + const projects = new Map() + for (const projectId of projectIds) { + projects.set(projectId, await loadProjectAccess(tx, userId, { projectId })) + } + const workspaces = workspaceIds.length + ? await tx + .select() + .from(workspace) + .where(and(inArray(workspace.id, workspaceIds), isNull(workspace.archivedAt))) + .orderBy(asc(workspace.id)) + .for('share') + : [] + const workspacesById = new Map(workspaces.map((row) => [row.id, row])) + const organizationIds = [ + ...new Set( + [ + ...[...projects.values()].map((access) => access.record.organizationId), + ...workspaces.map((row) => row.organizationId), + ].filter((id): id is string => id !== null) + ), + ].sort(compareStrings) + for (const organizationId of organizationIds) + await acquirePermissionGroupOrgLock(tx, organizationId) + await requireCurrentFileSubject(tx, principal) + if (principal.kind === 'resource_delegated') { + if (!invocationScope) throw new Error('Copilot copy invocation was not prepared') + for (const access of projects.values()) { + await requireCurrentCopilotProjectInvocation(tx, principal, access, invocationScope) + } + } + + function authorizeOwner( + owner: ResourceFileCopyScope['source']['owner'], + mode: 'read' | 'write' + ): Promise { + return requireFileOwnerAdapter( + COPY_OWNER_POLICIES, + owner + )({ + tx, + principal: actingPrincipal, + userId, + owner, + mode, + projects, + workspaces: workspacesById, + }) + } + + const source = await authorizeOwner(input.source.owner, 'read') + const destination = await authorizeOwner(input.destination.owner, 'write') + return { source, destination } + } +} diff --git a/apps/sim/lib/workspace-files/application/copy-file-items.ts b/apps/sim/lib/workspace-files/application/copy-file-items.ts new file mode 100644 index 00000000000..8e6883b9e44 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/copy-file-items.ts @@ -0,0 +1,240 @@ +import { isUtf8 } from 'node:buffer' +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { describeError, isPostgresCommitRejection } from '@sim/utils/errors' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' +import { maybeNotifyStorageLimitForBillingContext } from '@/lib/billing/storage/tracking' +import { + type AuthorizingUseCase, + recordProjectedUseCaseAuditEntries, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyFileListChanged } from '@/lib/realtime/notify' +import { assertFileFolderTarget } from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { + discardStagedFileContent, + planFileIdentity, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/file-manager' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { + getDocumentSourceLanguage, + getE2BDocFormat, + isCompiledDocumentBuffer, +} from '@/lib/uploads/documents' +import { getWorkspaceFileSize, MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { isMarkdownFile, isRenderableDocumentName } from '@/lib/uploads/utils/file' +import { + type CopyFileItemsInput, + createFileCopyAuthorizer, +} from '@/lib/workspace-files/application/copy-authorization' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' +import { + type CopiedFileItems, + commitFileCopyInTx, + requireUnchangedFileCopy, + snapshotFileCopyInTx, +} from '@/lib/workspace-files/copy' +import { rewriteCopiedFileReferences } from '@/lib/workspace-files/copy-references' +import { lockFileDirectories } from '@/lib/workspace-files/locks' + +const logger = createLogger('FileCopy') + +/** Atomic source-read/destination-write copy; staging never holds authorization or accounting locks. */ +export const copyFileItems: AuthorizingUseCase< + typeof fileCopyOperation, + CopyFileItemsInput, + CopiedFileItems +> = { + operation: fileCopyOperation, + delegationAudience: fileCopyOperation.delegationAudience, + async authorize(args) { + const input = structuredClone(args.input) + const authorize = await createFileCopyAuthorizer(args.principal, input) + await db.transaction(async (tx) => { + const context = await authorize(tx) + await lockFileDirectories(tx, [context.source.owner, context.destination.owner]) + await assertFileFolderTarget(context.destination.owner, input.destination.folderId, tx) + await snapshotFileCopyInTx(tx, { ...input.source, owner: context.source.owner }) + }) + }, + async execute(args) { + const input = structuredClone(args.input) + const authorize = await createFileCopyAuthorizer(args.principal, input) + // actorless-unsupported: the copy authorizer has rejected actorless principals; creator attribution retains its human subject. + const userId = requirePrincipalSubjectUserId(args.principal) + const prepared = await db.transaction(async (tx) => { + const context = await authorize(tx) + await lockFileDirectories(tx, [context.source.owner, context.destination.owner]) + await assertFileFolderTarget(context.destination.owner, input.destination.folderId, tx) + return { + context, + snapshot: await snapshotFileCopyInTx(tx, { ...input.source, owner: context.source.owner }), + } + }) + const staged = new Map() + const identities = new Map( + prepared.snapshot.files.map((file) => [ + file.id, + planFileIdentity(prepared.context.destination.owner), + ]) + ) + const fileIds = new Map([...identities].map(([sourceId, identity]) => [sourceId, identity.id])) + const fileKeys = new Map( + prepared.snapshot.files.map((file) => { + const id = fileIds.get(file.id) + if (!id) throw new Error('Copied file identity plan is incomplete') + return [file.key, id] + }) + ) + let stagedBytes = 0 + let callbackCompleted = false + const committed = await (async () => { + try { + for (const source of prepared.snapshot.files) { + const content = await runWithOutboundOrganization( + prepared.context.source.organizationId, + () => + downloadFile({ + key: source.key, + context: prepared.context.source.owner.entityType, + maxBytes: getWorkspaceFileSize(source), + }) + ) + if (content.length !== getWorkspaceFileSize(source)) { + throw new OrchestrationError('conflict', 'Source file size changed during copy') + } + const textual = + source.contentType.startsWith('text/') || + ['application/json', 'application/xml', 'application/javascript'].includes( + source.contentType + ) || + isMarkdownFile({ name: source.originalName, type: source.contentType }) || + isRenderableDocumentName(source.originalName) + let copiedContent = content + if ( + textual && + isUtf8(content) && + !isCompiledDocumentBuffer(source.originalName, content) + ) { + const text = content.toString('utf8') + const format = await getE2BDocFormat(source.originalName) + copiedContent = Buffer.from( + rewriteCopiedFileReferences( + text, + { + sourceOwner: prepared.context.source.owner, + destinationOwner: prepared.context.destination.owner, + fileIds, + fileKeys, + }, + format ? getDocumentSourceLanguage(text, format, source.contentType) : null + ) + ) + } + stagedBytes += copiedContent.length + if (stagedBytes > MAX_BUFFERED_TRANSFER_BYTES) + throw new OrchestrationError( + 'payload_too_large', + 'Copied content exceeds the buffered transfer limit' + ) + staged.set( + source.id, + await runWithOutboundOrganization(prepared.context.destination.organizationId, () => + stageFileContent({ + owner: prepared.context.destination.owner, + userId, + name: source.originalName, + contentType: source.contentType, + content: copiedContent, + }) + ) + ) + } + return await db.transaction(async (tx) => { + const context = await authorize(tx) + const accounting = await prepareFileAccountingInTx(tx, context.destination.owner) + await lockFileDirectories(tx, [context.source.owner, context.destination.owner]) + const current = await snapshotFileCopyInTx(tx, { + ...input.source, + owner: context.source.owner, + }) + requireUnchangedFileCopy(prepared.snapshot, current) + const result = await commitFileCopyInTx(tx, { + snapshot: current, + destination: { owner: context.destination.owner, folderId: input.destination.folderId }, + userId, + staged, + identities, + }) + const usage = await accounting.mutation.applyDelta(stagedBytes) + callbackCompleted = true + return { + context, + result, + billing: accounting.billing, + usage, + } + }) + } catch (error) { + if (callbackCompleted && !isPostgresCommitRejection(error)) { + logger.error('Copy commit outcome is uncertain; retaining staged content', { + error: describeError(error), + owner: prepared.context.destination.owner, + }) + throw error + } + const cleanups = await Promise.allSettled( + [...staged.values()].map(discardStagedFileContent) + ) + const failed = cleanups.find((cleanup) => cleanup.status === 'rejected') + if (failed?.status === 'rejected') { + throw new AggregateError( + [error, failed.reason], + 'Copy failed and durable cleanup could not be recorded' + ) + } + throw error + } + })() + const owner = committed.context.destination.owner + const workspaceId = owner.entityType === 'workspace' ? owner.entityId : null + const metadata = { source: input.source, destination: input.destination } + const entries: WorkspaceUseCaseAuditEntry[] = [ + ...committed.result.files.map((file) => ({ + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + metadata, + })), + ...committed.result.folders.map((folder) => ({ + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + metadata, + })), + ] + if (committed.result.files.length || committed.result.folders.length) { + await notifyFileListChanged(committed.context.destination.owner) + } + recordProjectedUseCaseAuditEntries( + fileCopyOperation, + workspaceId, + args.principal, + args.request, + entries, + committed.context.destination.organizationId ?? undefined + ) + if (committed.usage !== undefined) { + await maybeNotifyStorageLimitForBillingContext(committed.billing, committed.usage) + } + return committed.result + }, +} diff --git a/apps/sim/lib/workspace-files/application/copy-operation.ts b/apps/sim/lib/workspace-files/application/copy-operation.ts new file mode 100644 index 00000000000..9e70495fe8f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/copy-operation.ts @@ -0,0 +1,23 @@ +import { + assertOperationCapability, + assertOperationOAuthPolicy, +} from '@/lib/core/application/operation' + +export const FILE_COPY_DELEGATION_TTL_MS = 60_000 + +const policy = { + id: 'files.copy', + capability: 'files.use', + oauthScope: 'api:write', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'resource_delegated'], + delegatedServices: ['copilot'], + delegationAudience: 'sim:files:copy', +} as const + +assertOperationCapability(policy) +assertOperationOAuthPolicy(policy) +Object.freeze(policy.principalKinds) +Object.freeze(policy.delegatedServices) + +/** Compound copies bind independent source-read and destination-write authority. */ +export const fileCopyOperation = Object.freeze(policy) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index 6c4eaad3245..6864a907e6d 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -3,6 +3,7 @@ import { createSessionPrincipal, } from '@sim/testing/factories/principal.factory' import { auditMock } from '@sim/testing/mocks/audit.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { fileUtilsMock, fileUtilsMockFns } from '@sim/testing/mocks/file-utils.mock' import { permissionGroupsResolveMock, @@ -30,6 +31,8 @@ const { mockListWorkspaceFileFolders: mockListFolders, } = workspaceUploadsMockFns +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@/lib/permission-groups/resolve.server', () => permissionGroupsResolveMock) vi.mock('@/lib/uploads/contexts/workspace', () => workspaceUploadsMock) @@ -59,6 +62,8 @@ const mockProvenance = import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' +import { MAX_ZIP_DOWNLOAD_BYTES } from '@/lib/workspace-files/limits' +import { recheckFileReadReceipt } from '@/lib/workspace-files/read-receipt' const { mockResolveVerifiedUserAccessControlContext: mockAccessContext } = permissionGroupsResolveMockFns @@ -107,6 +112,7 @@ function file(id: string, name: string, folderId: string | null = null, size = 1 describe('downloadWorkspaceFileItems', () => { beforeEach(() => { events.length = 0 + vi.mocked(recheckFileReadReceipt).mockResolvedValue({ status: 'exact', entries: [] }) mockLoadContext.mockImplementation(async () => { events.push('resolve') return workspace @@ -144,7 +150,7 @@ describe('downloadWorkspaceFileItems', () => { ) expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) - expect(mockResolvePermission).toHaveBeenCalledOnce() + expect(mockResolvePermission).toHaveBeenCalledTimes(2) expect(mockProvenance).toHaveBeenCalledWith('ws-1', [ expect.objectContaining({ fileId: 'f1', key: 'key-f1' }), ]) @@ -172,38 +178,42 @@ describe('downloadWorkspaceFileItems', () => { expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) }) - it('counts only the zipped files once a rendered document is left out', async () => { - mockListFiles.mockResolvedValue([ - file('f1', 'clean.txt', null, 10), - { ...file('f2', 'report.docx', null, 25), type: 'text/x-generated-document' }, - ]) - mockIsGenerated.mockImplementation((type) => type === 'text/x-generated-document') - mockFetchServable.mockResolvedValue({ - buffer: Buffer.from('rendered'), - contributingFiles: [{ fileId: 'c1', key: 'key-c1', context: 'workspace' }], - }) - mockProvenance.mockImplementation( - async (_workspaceId: string, identities: readonly { fileId: string }[]) => - new Map( - identities.map((identity) => [ - identity.fileId, - identity.fileId === 'c1' ? { status: 'unknown' } : { status: 'exact', entries: [] }, - ]) - ) - ) - const result = await observeContentDelivery(secretFreeGate, () => - downloadWorkspaceFileItems.execute({ - principal: createDelegatedPrincipal({ - subjectUserId: 'u1', - workspaceId: 'ws-1', - audience: 'sim:workspace-files', - }), - input: { workspaceId: 'ws-1', fileIds: ['f1', 'f2'], folderIds: [] }, + it.each([10, MAX_ZIP_DOWNLOAD_BYTES])( + 'counts only admitted bytes with %i streamed bytes', + async (streamedBytes) => { + mockListFiles.mockResolvedValue([ + file('f1', 'clean.txt', null, streamedBytes), + { ...file('f2', 'report.docx', null, 25), type: 'text/x-generated-document' }, + ]) + mockIsGenerated.mockImplementation((type) => type === 'text/x-generated-document') + mockFetchServable.mockResolvedValue({ + buffer: Buffer.from('rendered'), + contributingFiles: [{ fileId: 'c1', key: 'key-c1', context: 'workspace' }], }) - ) - expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) - expect(result.declaredBytes).toBe(10) - }) + mockProvenance.mockImplementation( + async (_workspaceId: string, identities: readonly { fileId: string }[]) => + new Map( + identities.map((identity) => [ + identity.fileId, + identity.fileId === 'c1' ? { status: 'unknown' } : { status: 'exact', entries: [] }, + ]) + ) + ) + const result = await observeContentDelivery(secretFreeGate, () => + downloadWorkspaceFileItems.execute({ + principal: createDelegatedPrincipal({ + subjectUserId: 'u1', + workspaceId: 'ws-1', + audience: 'sim:workspace-files', + }), + input: { workspaceId: 'ws-1', fileIds: ['f1', 'f2'], folderIds: [] }, + }) + ) + expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) + expect(result.declaredBytes).toBe(streamedBytes) + expect(mockFetchServable.mock.calls[0][2].maxBytes).toBeGreaterThan(0) + } + ) it('refuses a Chat archive whose every file its gate refuses', async () => { mockProvenance.mockResolvedValue(new Map([['f1', { status: 'unknown' }]])) @@ -310,6 +320,27 @@ describe('downloadWorkspaceFileItems', () => { ).rejects.toMatchObject({ code: 'validation' }) }) + it('refuses refreshed unsafe provenance even after initial admission succeeds', async () => { + vi.mocked(recheckFileReadReceipt).mockResolvedValue({ status: 'unknown' }) + await expect( + observeContentDelivery(secretFreeGate, () => + downloadWorkspaceFileItems.execute({ + principal: delegatedPrincipal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + ) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(recheckFileReadReceipt).toHaveBeenCalled() + }) + + it('rejects an unknown folder in a mixed selection', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: ['missing'] }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) describe('permission-group capability', () => { beforeEach(() => { mockAccessContext.mockResolvedValue({ @@ -328,6 +359,15 @@ describe('downloadWorkspaceFileItems', () => { }) }) + it('refuses a HEAD authorization probe when bulk download is withheld', async () => { + await expect( + downloadWorkspaceFileItems.authorize({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + ).rejects.toMatchObject({ capability: 'files.bulk_download' }) + expect(events).not.toContain('execute') + }) it('refuses a folder archive when the group withholds files.bulk_download', async () => { await expect( downloadWorkspaceFileItems.execute({ diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index 24326849656..02d0da9d359 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -1,12 +1,14 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { compareStrings } from '@sim/utils/string' import { type AuthorizedWorkspaceUseCaseContext, ContentRefusedError, capabilityGovernedPrincipalUserId, } from '@/lib/core/application' +import { requireAdmittedContent } from '@/lib/core/application/content-delivery' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { parseFolderPath } from '@/lib/folders/paths' import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { buildWorkspaceFileFolderPathMap, @@ -15,6 +17,7 @@ import { loadWorkspaceFileOperationContext, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace' +import { mergeWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/doc-not-ready' import { formatFileSize, @@ -28,12 +31,16 @@ import { import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' -import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' - -const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 -const MAX_REQUESTED_FILE_IDS = 1_000 -const MAX_REQUESTED_FOLDER_IDS = 1_000 +import { + expandFileDownloadFolders, + normalizeFileDownloadSelection, +} from '@/lib/workspace-files/download-selection' +import { MAX_ZIP_DOWNLOAD_BYTES, MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' +import { + createFileReadReceipt, + type FileReadReceipt, + recheckFileReadReceipt, +} from '@/lib/workspace-files/read-receipt' export interface DownloadWorkspaceFileItemsInput { workspaceId: string @@ -55,51 +62,6 @@ export interface DownloadWorkspaceFileItemsResult { excludedFileIds: string[] } -function collectDescendantFolderIds( - selectedFolderIds: string[], - folders: Array<{ id: string; parentId: string | null }> -): Set { - const folderIds = new Set(selectedFolderIds) - let changed = true - while (changed) { - changed = false - for (const folder of folders) { - if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { - folderIds.add(folder.id) - changed = true - } - } - } - return folderIds -} - -/** - * Maps canonical folder paths onto the ids the selection walk uses. - * - * Resolved against the folder set the download already loads rather than by a - * separate path query, and a path that matches nothing is rejected rather than - * silently dropped β€” a caller that misspells a folder should not receive a zip - * of whatever else it happened to select. - */ -function resolveFolderIdsFromPaths( - paths: string[], - folders: Array<{ id: string }>, - displayPathById: Map -): string[] { - if (paths.length === 0) return [] - const idByPath = new Map() - for (const folder of folders) { - const displayPath = displayPathById.get(folder.id) - if (!displayPath) continue - idByPath.set(parseWorkspaceFileFolderDisplayPath(displayPath).join('\u0000'), folder.id) - } - return paths.map((path) => { - const id = idByPath.get(parseFolderPath(path).join('\u0000')) - if (!id) validationError(`Folder not found: ${path}`) - return id - }) -} - function validationError(message: string): never { throw new OrchestrationError('validation', message) } @@ -112,30 +74,14 @@ function validateDeclaredBytes(bytes: number): void { } } -async function executeDownloadWorkspaceFileItems({ - input, +async function requireDownloadCapability({ context, principal, }: AuthorizedWorkspaceUseCaseContext< typeof fileOperations.download, DownloadWorkspaceFileItemsInput, Awaited> ->): Promise { - const fileIds = [...new Set(input.fileIds)] - const folderIds = [...new Set(input.folderIds)] - const requestedFolderPaths = [...new Set(input.folderPaths ?? [])] - if (fileIds.length > MAX_REQUESTED_FILE_IDS) { - validationError(`Too many file IDs selected. Select ${MAX_REQUESTED_FILE_IDS} or fewer files.`) - } - if (folderIds.length + requestedFolderPaths.length > MAX_REQUESTED_FOLDER_IDS) { - validationError( - `Too many folders selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` - ) - } - if (fileIds.length === 0 && folderIds.length === 0 && requestedFolderPaths.length === 0) { - validationError('No files selected for download') - } - +>) { /** * permission-group-enforced: files.bulk_download β€” one operation serves both * a single file and a whole folder tree, and only the archive is what the key @@ -160,17 +106,26 @@ async function executeDownloadWorkspaceFileItems({ ) } } +} + +async function executeDownloadWorkspaceFileItems({ + input, + context, + principal, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileItemsInput, + Awaited> +>): Promise { + const selection = normalizeFileDownloadSelection(input) const [files, folders] = await Promise.all([ listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), listWorkspaceFileFolders(context.workspaceId), ]) const folderPaths = buildWorkspaceFileFolderPathMap(folders) - const selectedFolderIds = collectDescendantFolderIds( - [...folderIds, ...resolveFolderIdsFromPaths(requestedFolderPaths, folders, folderPaths)], - folders - ) - const requestedFileIds = new Set(fileIds) + const selectedFolderIds = expandFileDownloadFolders(selection, folders, folderPaths) + const requestedFileIds = new Set(selection.fileIds) const selectedFiles = files.filter( (file) => requestedFileIds.has(file.id) || @@ -197,25 +152,30 @@ async function executeDownloadWorkspaceFileItems({ let declaredBytes = reservedForStreamed validateDeclaredBytes(declaredBytes) const renderedDocuments = new Map() + const receipts: FileReadReceipt[] = [] const pendingNames: string[] = [] let renderedBytes = 0 for (const file of filesToZip) { if (!needsRenderedArtifact(file.type, file.name)) continue const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) - const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) try { - const { buffer, contributingFiles } = await fetchAuthorizedServableWorkspaceFileBuffer( - file, - principal, - { maxBytes: allowance } - ) + const { buffer, contributingFiles, receipt } = + await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, { + maxBytes: MAX_RENDERED_DOCUMENT_BYTES, + }) if (!(await admitContributors(principal, context.workspaceId, contributingFiles, 'opaque'))) { filesToZip = filesToZip.filter((admitted) => admitted.id !== file.id) continue } + if (buffer.length > remaining) { + validationError( + `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.` + ) + } declaredBytes += file.size validateDeclaredBytes(declaredBytes) + receipts.push(receipt) renderedBytes += buffer.length renderedDocuments.set(file.id, buffer) } catch (error) { @@ -225,9 +185,7 @@ async function executeDownloadWorkspaceFileItems({ } if (error instanceof PayloadSizeLimitError) { validationError( - allowance === MAX_RENDERED_DOCUMENT_BYTES - ? `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.` - : `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.` + `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.` ) } if (!isDocNotReadyError(error)) throw error @@ -242,6 +200,67 @@ async function executeDownloadWorkspaceFileItems({ throw new ContentRefusedError('workspace-file') } + await downloadWorkspaceFileItems.authorize({ principal, input }) + if (context.fileId === undefined) { + const actingUserId = capabilityGovernedPrincipalUserId(principal) + if (actingUserId) { + // permission-group-enforced: files.bulk_download β€” publication must use the current archive capability. + await assertWorkspaceCapability( + actingUserId, + context.workspaceId, + 'files.bulk_download', + context.workspaceOrganizationId + ) + } + } + const [currentFiles, currentFolders] = await Promise.all([ + listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), + listWorkspaceFileFolders(context.workspaceId), + ]) + const currentPaths = buildWorkspaceFileFolderPathMap(currentFolders) + const currentSelection = expandFileDownloadFolders(selection, currentFolders, currentPaths) + const currentSelectedFiles = currentFiles.filter( + (file) => + requestedFileIds.has(file.id) || + (file.folderId != null && currentSelection.has(file.folderId)) + ) + const identity = (selected: WorkspaceFileRecord[], paths: Map) => + JSON.stringify( + [...selected] + .sort((a, b) => compareStrings(a.id, b.id)) + .map((file) => [ + file.id, + file.key, + file.name, + file.folderId, + file.folderId ? paths.get(file.folderId) : null, + ]) + ) + if (identity(currentSelectedFiles, currentPaths) !== identity(selectedFiles, folderPaths)) + throw new OrchestrationError('conflict', 'File selection changed while preparing the download') + receipts.push( + createFileReadReceipt( + { entityType: 'workspace', entityId: context.workspaceId }, + filesToZip.map((file) => ({ + ...file, + contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt, + })) + ) + ) + const provenance = await db.transaction( + async (tx) => { + const evidence = [] + for (const receipt of receipts) evidence.push(await recheckFileReadReceipt(tx, receipt)) + return mergeWorkspaceFileSecretProvenance(...evidence) + }, + { isolationLevel: 'repeatable read', accessMode: 'read only' } + ) + await requireAdmittedContent(principal, { + kind: 'workspace-file', + id: context.workspaceId, + provenance, + view: 'opaque', + }) const included = new Set(filesToZip.map((file) => file.id)) return { excludedFileIds: selectedFiles.filter((file) => !included.has(file.id)).map((file) => file.id), @@ -275,6 +294,7 @@ async function resolveDownloadContext({ input }: { input: DownloadWorkspaceFileI export const downloadWorkspaceFileItems = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.download, resolveContext: resolveDownloadContext, + authorizeResource: requireDownloadCapability, execute: executeDownloadWorkspaceFileItems, projectAudit({ result }) { return { diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts index b002a7dd1be..f25303b218e 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts @@ -4,6 +4,7 @@ import { createSessionPrincipal, } from '@sim/testing/factories/principal.factory' import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' import { @@ -22,6 +23,10 @@ import { observeContentDelivery, } from '@/lib/core/application' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' +import { + assertFileReadReceiptCurrent, + recheckFileReadReceipt, +} from '@/lib/workspace-files/read-receipt' class FakeDocNotReadyError extends Error {} @@ -29,6 +34,8 @@ const hoisted = vi.hoisted(() => ({ fetchServable: vi.fn(), })) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@sim/audit', () => auditMock) vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) @@ -142,7 +149,7 @@ describe('workspace file downloads', () => { downloadWorkspaceFileStream.execute({ principal: CHAT, input: { fileId: plainFile.id } }) ) expect(admit).toHaveBeenCalledWith([ - { kind: 'workspace-file', id: plainFile.id, provenance, view: 'complete' }, + { kind: 'workspace-file', id: plainFile.id, provenance, view: 'opaque' }, ]) expect(admit.mock.invocationCallOrder[0]).toBeLessThan( mocks.downloadStream.mock.invocationCallOrder[0] @@ -161,6 +168,22 @@ describe('workspace file downloads', () => { expect(mocks.downloadStream).not.toHaveBeenCalled() }) + it('refuses raw bytes when final receipt provenance is no longer admitted', async () => { + mocks.getFile.mockResolvedValue(plainFile) + mocks.provenance.mockResolvedValue({ status: 'exact', entries: [] }) + vi.mocked(recheckFileReadReceipt).mockResolvedValueOnce({ + status: 'unknown', + }) + await expect( + observeContentDelivery( + { admit: async (sources) => sources.map((source) => source.provenance.status === 'exact') }, + () => + downloadWorkspaceFileStream.execute({ principal: CHAT, input: { fileId: plainFile.id } }) + ) + ).rejects.toBeInstanceOf(ContentRefusedError) + expect(mocks.downloadStream).toHaveBeenCalled() + }) + it('refuses a Chat download no gate observes', async () => { mocks.getFile.mockResolvedValue(plainFile) await expect( @@ -194,6 +217,24 @@ describe('workspace file downloads', () => { ).rejects.toBeInstanceOf(ContentRefusedError) }) + it('rejects a session download whose files changed after authorization', async () => { + mocks.getFile.mockResolvedValue(generatedDoc) + mocks.fetchServable.mockResolvedValue({ + buffer: Buffer.from('%PDF-compiled'), + contentType: 'application/pdf', + receipt: { + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + files: [ + { id: generatedDoc.id, key: generatedDoc.key, revision: 1 }, + { id: 'file-embedded', key: 'workspace/workspace-1/logo.png', revision: 1 }, + ], + }, + }) + vi.mocked(assertFileReadReceiptCurrent).mockRejectedValueOnce(new Error('A file changed')) + + await expect(downloadStream(generatedDoc.id)).rejects.toThrow('A file changed') + }) + it('reads no provenance for a session download, which no gate classifies', async () => { mocks.getFile.mockResolvedValue(plainFile) const result = await downloadStream(plainFile.id) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts index 7c57b67de71..47d47fa8e17 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -15,8 +15,10 @@ import { } from '@/lib/workspace-files/application/admit-file-content' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/file-context' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact' +import { createFileReadReceipt, type FileReadReceipt } from '@/lib/workspace-files/read-receipt' export interface DownloadWorkspaceFileInput { fileId: string @@ -36,6 +38,7 @@ export interface DownloadWorkspaceFileStreamResult extends DownloadWorkspaceFile */ contentLength: number contentType: string + receipt?: FileReadReceipt } /** Audits the bytes actually handed out, which for a generated doc is not `file.size`. */ @@ -88,6 +91,7 @@ function resolveRenderedArtifact( } async function executeDownloadWorkspaceFileStream({ + input, context, principal, }: AuthorizedWorkspaceUseCaseContext< @@ -99,8 +103,25 @@ async function executeDownloadWorkspaceFileStream({ throwOnError: true, }) if (!file) throw new OrchestrationError('not_found', 'File not found') - await requireAdmittedWorkspaceFile(principal, context.workspaceId, file, 'complete') - return streamWorkspaceFileRecord(file, principal) + await requireAdmittedWorkspaceFile(principal, context.workspaceId, file, 'opaque') + const result = await streamWorkspaceFileRecord(file, principal) + const provenance = await finishFileDelivery({ + principal, + authorize: () => downloadWorkspaceFileStream.authorize({ principal, input }), + receipt: + result.receipt ?? + createFileReadReceipt({ entityType: 'workspace', entityId: context.workspaceId }, [ + { ...file, contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt }, + ]), + stream: result.stream, + }) + try { + await requireAdmittedWorkspaceFile(principal, context.workspaceId, file, 'opaque', provenance) + return result + } catch (error) { + await result.stream.cancel(error).catch(() => undefined) + throw error + } } /** @@ -121,11 +142,11 @@ export async function streamWorkspaceFileRecord( * double peak memory. */ if (needsRenderedArtifact(file.type, file.name)) { - const { buffer, contentType, contributingFiles } = await resolveRenderedArtifact( + const { buffer, contentType, contributingFiles, receipt } = await resolveRenderedArtifact( file, principal ) - await requireAdmittedContributors(principal, file.workspaceId, contributingFiles, 'complete') + await requireAdmittedContributors(principal, file.workspaceId, contributingFiles, 'opaque') return { file, stream: new ReadableStream({ @@ -138,6 +159,7 @@ export async function streamWorkspaceFileRecord( }), contentLength: buffer.length, contentType, + receipt, } } diff --git a/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts index 2b3ca630d32..959c18bd3b0 100644 --- a/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts @@ -37,6 +37,7 @@ vi.mock('@/lib/realtime/notify', () => realtimeNotifyMock) vi.mock('@/lib/core/idempotency/service', () => idempotencyServiceMock) vi.mock('@/lib/uploads/archive', () => ({ + archiveFolderName: vi.fn(() => 'bundle'), decompressArchiveBufferToWorkspaceFiles: hoisted.decompress, MAX_ARCHIVE_BYTES: 100 * 1024 * 1024, })) diff --git a/apps/sim/lib/workspace-files/application/extract-workspace-file.ts b/apps/sim/lib/workspace-files/application/extract-workspace-file.ts index 9dcf6d60e70..44f5176eb96 100644 --- a/apps/sim/lib/workspace-files/application/extract-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/extract-workspace-file.ts @@ -6,7 +6,11 @@ import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { IdempotencyService } from '@/lib/core/idempotency/service' import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' -import { decompressArchiveBufferToWorkspaceFiles, MAX_ARCHIVE_BYTES } from '@/lib/uploads/archive' +import { + archiveFolderName, + decompressArchiveBufferToWorkspaceFiles, + MAX_ARCHIVE_BYTES, +} from '@/lib/uploads/archive' import { archiveWorkspaceFileFolderIfEmpty, createWorkspaceFileFolder, @@ -73,16 +77,6 @@ type ExtractWorkspaceFileUseCaseContext = AuthorizedWorkspaceUseCaseContext< ActiveWorkspaceFileContext > -function archiveFolderName(fileName: string): string { - const stripped = fileName - .replace(/\.zip$/i, '') - .normalize('NFC') - .replace(/[\x00-\x1f\x7f]/g, '') - .replace(/[/\\]/g, '-') - .trim() - return stripped && stripped !== '.' && stripped !== '..' ? stripped : 'archive' -} - async function withExtractionLease( workspaceId: string, fileId: string, diff --git a/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts b/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts index 30304f6f85e..9a8296ab80e 100644 --- a/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts +++ b/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts @@ -1,9 +1,11 @@ -import type { Principal } from '@sim/auth/principal' +import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { downloadServableFileFromStorage, type ServableFile } from '@/lib/uploads/utils/file.server' +import { createFileReadReceipt, type FileReadReceipt } from '@/lib/workspace-files/read-receipt' import { requeueFileSearchForRenderedArtifact } from '@/lib/workspace-files/search/artifact-ready' const logger = createLogger('FetchServableWorkspaceFileBuffer') @@ -17,7 +19,8 @@ export async function fetchAuthorizedServableWorkspaceFileBuffer( fileRecord: WorkspaceFileRecord, filePrincipal: Principal, options: { maxBytes: number; signal?: AbortSignal; requestId?: string } -): Promise { +): Promise { + const subjectUserId = resolvePrincipalSubjectUserId(filePrincipal) const result = await downloadServableFileFromStorage( { id: fileRecord.id, @@ -33,6 +36,7 @@ export async function fetchAuthorizedServableWorkspaceFileBuffer( { ...options, filePrincipal, + ownerKey: subjectUserId ? `user:${subjectUserId}` : `workspace:${fileRecord.workspaceId}`, } ) const artifactKey = result.artifactKey @@ -68,5 +72,20 @@ export async function fetchAuthorizedServableWorkspaceFileBuffer( }) } } - return result + const receipt = createFileReadReceipt( + { entityType: 'workspace', entityId: fileRecord.workspaceId }, + [ + { ...fileRecord, contentUpdatedAt: fileRecord.contentUpdatedAt ?? fileRecord.updatedAt }, + ...(result.contributingFiles ?? []).map((file) => { + if (!['workspace', 'test', 'changelog'].includes(file.context) || !file.contentUpdatedAt) + throw new OrchestrationError('conflict', 'Document input has no canonical revision') + return { id: file.fileId, key: file.key, contentUpdatedAt: file.contentUpdatedAt } + }), + ] + ) + return { + ...result, + receipt, + dependsOnReferencedFiles: result.dependsOnReferencedFiles ?? receipt.files.length > 1, + } } diff --git a/apps/sim/lib/workspace-files/application/file-versions.test.ts b/apps/sim/lib/workspace-files/application/file-versions.test.ts index 8c2ff3e8e28..f9abd524c5b 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.test.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.test.ts @@ -1,5 +1,9 @@ -import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { + createDelegatedPrincipal, + createSessionPrincipal, +} from '@sim/testing/factories/principal.factory' import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { realtimeNotifyMock, realtimeNotifyMockFns } from '@sim/testing/mocks/realtime-notify.mock' import { uploadsMock } from '@sim/testing/mocks/uploads.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' @@ -20,6 +24,8 @@ const hoisted = vi.hoisted(() => ({ streamRecord: vi.fn(), })) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) vi.mock('@sim/audit', () => auditMock) @@ -45,6 +51,7 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision' import { deleteWorkspaceFileVersion, + downloadWorkspaceFileVersion, revertWorkspaceFileVersion, } from '@/lib/workspace-files/application/file-versions' @@ -130,6 +137,16 @@ describe('file version use cases', () => { mocks.notify.mockResolvedValue(undefined) }) + it('rejects Copilot version downloads before storage because the operation accepts direct principals only', async () => { + await expect( + downloadWorkspaceFileVersion.execute({ + principal: createDelegatedPrincipal({ audience: 'sim:workspace-files' }), + input: { fileId: file.id, version: 2 }, + }) + ).rejects.toMatchObject({ name: 'PrincipalKindAuthorizationError' }) + expect(mocks.streamRecord).not.toHaveBeenCalled() + }) + describe('revertWorkspaceFileVersion', () => { it('writes the version as a new revert version carrying its provenance snapshot', async () => { mocks.getVersion.mockImplementation(getVersionAfterRevert) diff --git a/apps/sim/lib/workspace-files/application/file-versions.ts b/apps/sim/lib/workspace-files/application/file-versions.ts index 247ff84cafa..ffb38f4916f 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.ts @@ -41,6 +41,7 @@ import { import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/file-context' import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' import { prepareOwnedFileContentWrite } from '@/lib/workspace-files/application/owned-file-content' import { @@ -250,6 +251,25 @@ const downloadVersion = defineAuthorizedWorkspaceFileUseCase({ const result = await readFileVersionObject(version.version, () => streamWorkspaceFileRecord(recordAtVersion(file, version), principal) ) + await finishFileDelivery({ + principal, + async authorize() { + await downloadVersion.authorize({ principal, input }) + const currentFile = await loadActiveFile(context) + const current = await loadVersion(currentFile, input.version) + if ( + current.key !== version.key || + (version.isCurrent && + currentFile.contentUpdatedAt?.getTime() !== file.contentUpdatedAt?.getTime()) + ) + throw new OrchestrationError('conflict', 'The selected version changed during download') + }, + receipt: { + owner: { entityType: 'workspace', entityId: context.workspaceId }, + files: result.receipt?.files.filter((entry) => entry.id !== file.id) ?? [], + }, + stream: result.stream, + }) return { ...result, file, version } }, projectAudit: ({ result }) => ({ diff --git a/apps/sim/lib/workspace-files/application/finish-file-delivery.ts b/apps/sim/lib/workspace-files/application/finish-file-delivery.ts new file mode 100644 index 00000000000..e8ea618b959 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/finish-file-delivery.ts @@ -0,0 +1,36 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { mustClassifyContent } from '@/lib/core/application' +import { + assertFileReadReceiptCurrent, + type FileReadReceipt, + recheckFileReadReceipt, +} from '@/lib/workspace-files/read-receipt' + +/** + * Recheck current application authority and byte identities before publishing; dispose rejected + * streams. Only a classified principal gets the delivered files' current provenance back: no other + * caller's admission reads it. + */ +export async function finishFileDelivery(input: { + principal: Principal + authorize(): Promise + receipt: FileReadReceipt + stream?: ReadableStream +}) { + try { + const classified = mustClassifyContent(input.principal) + await input.authorize() + return await db.transaction( + async (tx) => { + if (classified) return recheckFileReadReceipt(tx, input.receipt) + await assertFileReadReceiptCurrent(tx, input.receipt) + return undefined + }, + { isolationLevel: 'repeatable read', accessMode: 'read only' } + ) + } catch (error) { + await input.stream?.cancel(error).catch(() => undefined) + throw error + } +} diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index c96ff002871..d84ffe529ad 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -1,4 +1,8 @@ import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' + +/** Compound copy admission remains separate from workspace-only file operations. */ +export const fileCopyOperations = Object.freeze({ copy: fileCopyOperation }) const ALL_COPILOT_PRINCIPAL_POLICY = { principalKinds: [ diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts index 3c27fe48383..d19acf2e6f2 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts @@ -1,17 +1,35 @@ import { + createDelegatedPrincipal, createSessionPrincipal, createWorkspaceApiKeyPrincipal, } from '@sim/testing/factories/principal.factory' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { uploadsMetadataMock, uploadsMetadataMockFns, } from '@sim/testing/mocks/uploads-metadata.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' +import { + workspaceFileSecretProvenanceMock, + workspaceFileSecretProvenanceMockFns, +} from '@sim/testing/mocks/workspace-file-secret-provenance.mock' import { workspaceUploadsMock, workspaceUploadsMockFns, } from '@sim/testing/mocks/workspace-uploads.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ContentRefusedError, + observeContentDelivery, +} from '@/lib/core/application/content-delivery' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' +import { recheckFileReadReceipt } from '@/lib/workspace-files/read-receipt' + +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) +vi.mock( + '@/lib/uploads/contexts/workspace/file-secret-provenance', + () => workspaceFileSecretProvenanceMock +) vi.mock('@/lib/uploads/contexts/workspace', () => workspaceUploadsMock) @@ -68,6 +86,21 @@ describe('readWorkspaceFileContentByKey', () => { mocks.fetchContent.mockResolvedValue(Buffer.from('source')) }) + it('refuses bytes when final receipt provenance changes after storage opens', async () => { + const chat = createDelegatedPrincipal({ audience: WORKSPACE_FILES_DELEGATION_AUDIENCE }) + workspaceFileSecretProvenanceMockFns.mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue( + { status: 'exact', entries: [] } + ) + vi.mocked(recheckFileReadReceipt).mockResolvedValueOnce({ status: 'unknown' }) + await expect( + observeContentDelivery( + { admit: async (sources) => sources.map((source) => source.provenance.status === 'exact') }, + () => readWorkspaceFileContentByKey.execute({ principal: chat, input: { key: file.key } }) + ) + ).rejects.toBeInstanceOf(ContentRefusedError) + expect(mocks.fetchContent).toHaveBeenCalled() + }) + it('authorizes the canonical file before returning its stored bytes', async () => { await expect( readWorkspaceFileContentByKey.execute({ diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts index f7cb38a5c5a..94fe159d275 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts @@ -13,8 +13,10 @@ import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { requireAdmittedWorkspaceFile } from '@/lib/workspace-files/application/admit-file-content' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { assertOwnedFileAccess } from '@/lib/workspace-files/application/file-context' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' import { OWNED_FILE_CONTEXTS, ownedFileKind } from '@/lib/workspace-files/owned-files' +import { createFileReadReceipt } from '@/lib/workspace-files/read-receipt' export interface ReadWorkspaceFileByKeyInput { key: string @@ -53,10 +55,16 @@ async function executeReadWorkspaceFileContentByKey({ >): Promise { const file = await loadCurrentWorkspaceFileByKey(input, context) await requireAdmittedWorkspaceFile(principal, context.workspaceId, file, 'opaque') - return { - file, - content: await fetchWorkspaceFileBuffer(file, { maxBytes: MAX_BUFFERED_TRANSFER_BYTES }), - } + const content = await fetchWorkspaceFileBuffer(file, { maxBytes: MAX_BUFFERED_TRANSFER_BYTES }) + const provenance = await finishFileDelivery({ + principal, + authorize: () => readWorkspaceFileContentByKey.authorize({ principal, input }), + receipt: createFileReadReceipt({ entityType: 'workspace', entityId: context.workspaceId }, [ + { ...file, contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt }, + ]), + }) + await requireAdmittedWorkspaceFile(principal, context.workspaceId, file, 'opaque', provenance) + return { file, content } } /** Workspace files and owned files share the `workspace/` key prefix; the row says which. */ diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts index 59ad41f7c45..a5bf732fca3 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts @@ -1,5 +1,9 @@ import { Readable } from 'node:stream' -import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { + createDelegatedPrincipal, + createSessionPrincipal, +} from '@sim/testing/factories/principal.factory' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { uploadsMetadataMock, @@ -10,7 +14,23 @@ import { workspaceFileManagerMock, workspaceFileManagerMockFns, } from '@sim/testing/mocks/workspace-file-manager.mock' +import { + workspaceFileSecretProvenanceMock, + workspaceFileSecretProvenanceMockFns, +} from '@sim/testing/mocks/workspace-file-secret-provenance.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ContentRefusedError, + observeContentDelivery, +} from '@/lib/core/application/content-delivery' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' +import { recheckFileReadReceipt } from '@/lib/workspace-files/read-receipt' + +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) +vi.mock( + '@/lib/uploads/contexts/workspace/file-secret-provenance', + () => workspaceFileSecretProvenanceMock +) vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) vi.mock('@/lib/uploads/contexts/workspace/file-manager', () => workspaceFileManagerMock) @@ -50,6 +70,28 @@ describe('readWorkspaceInlineFile', () => { mockDownloadFileStream.mockResolvedValue(Readable.from(Buffer.from('png'))) }) + it('refuses bytes when final receipt provenance changes after storage opens', async () => { + const chat = createDelegatedPrincipal({ + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + workspaceId: 'ws-1', + }) + workspaceFileSecretProvenanceMockFns.mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue( + { status: 'exact', entries: [] } + ) + vi.mocked(recheckFileReadReceipt).mockResolvedValueOnce({ status: 'unknown' }) + await expect( + observeContentDelivery( + { admit: async (sources) => sources.map((source) => source.provenance.status === 'exact') }, + () => + readWorkspaceInlineFile.execute({ + principal: chat, + input: { workspaceId: 'ws-1', fileId: 'f1' }, + }) + ) + ).rejects.toBeInstanceOf(ContentRefusedError) + expect(mockDownloadFileStream).toHaveBeenCalled() + }) + it('authorizes a file-id reference against the asserted workspace before reading bytes', async () => { const result = await readWorkspaceInlineFile.execute({ principal, diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts index 6ab39d205ab..ef45d1aaf00 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts @@ -11,7 +11,9 @@ import { downloadFileStream } from '@/lib/uploads/core/storage-service' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { requireAdmittedWorkspaceFile } from '@/lib/workspace-files/application/admit-file-content' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { createFileReadReceipt } from '@/lib/workspace-files/read-receipt' export interface ReadWorkspaceInlineFileInput { workspaceId: string @@ -50,10 +52,26 @@ async function executeReadWorkspaceInlineFile({ if (!file) throw new OrchestrationError('not_found', 'Not found') await requireAdmittedWorkspaceFile(principal, context.workspaceId, file, 'opaque') - const stream = await downloadFileStream({ key: file.key, context: 'workspace' }) + const stream = nodeReadableToWebStream( + await downloadFileStream({ key: file.key, context: 'workspace' }) + ) + const provenance = await finishFileDelivery({ + principal, + authorize: () => readWorkspaceInlineFile.authorize({ principal, input }), + receipt: createFileReadReceipt({ entityType: 'workspace', entityId: context.workspaceId }, [ + { ...file, contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt }, + ]), + stream, + }) + try { + await requireAdmittedWorkspaceFile(principal, context.workspaceId, file, 'opaque', provenance) + } catch (error) { + await stream.cancel(error).catch(() => undefined) + throw error + } return { file, - stream: nodeReadableToWebStream(stream), + stream, contentAddressed: input.key !== undefined && input.key === file.key, } } diff --git a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts index 89d9ded9ac0..5428ee2249a 100644 --- a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts +++ b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts @@ -1,7 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { resolveDocumentRender } from '@/lib/uploads/documents' -import type { ServableFile } from '@/lib/uploads/utils/file.server' +import { resolveDocumentRender } from '@/lib/uploads/documents/render' import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' /** @@ -29,7 +28,7 @@ export async function resolveRenderedWorkspaceArtifact( file: WorkspaceFileRecord, filePrincipal: Principal, options: { maxBytes: number; signal?: AbortSignal; tooLargeMessage?: (limit: string) => string } -): Promise { +) { return resolveDocumentRender(file.name, options, () => fetchAuthorizedServableWorkspaceFileBuffer(file, filePrincipal, { maxBytes: options.maxBytes, diff --git a/apps/sim/lib/workspace-files/archive-extraction.ts b/apps/sim/lib/workspace-files/archive-extraction.ts new file mode 100644 index 00000000000..54a389d6de2 --- /dev/null +++ b/apps/sim/lib/workspace-files/archive-extraction.ts @@ -0,0 +1,175 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { MAX_ARCHIVE_TOTAL_BYTES, type PreparedArchiveExtraction } from '@/lib/uploads/archive' +import { createFileFolder } from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { + commitFileCreateInTx, + discardStagedFileContent, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { restoreSimPageSourceBuffer } from '@/lib/workspace-files/page-source-embed' + +interface StagedArchiveEntry { + readonly parentSegments: readonly string[] + readonly content: StagedFileContent +} + +interface StagedFileArchive { + readonly entries: readonly StagedArchiveEntry[] + readonly bytes: number + readonly plan: PreparedArchiveExtraction +} + +/** Releases staged objects through the existing durable compensation path. */ +export async function discardFileArchive(archive: Pick) { + const results = await Promise.allSettled( + archive.entries.map((entry) => discardStagedFileContent(entry.content)) + ) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length) throw new AggregateError(failures, 'Archive cleanup could not be recorded') +} + +/** Inflates and stages one bounded entry at a time, without holding database locks. */ +export async function stageFileArchive( + plan: PreparedArchiveExtraction, + args: { owner: EditableFileOwner; userId: string; signal: AbortSignal } +): Promise { + const entries: StagedArchiveEntry[] = [] + let bytes = 0 + try { + for await (const entry of plan.entries(args.signal)) { + args.signal.throwIfAborted() + if (entry.segments.slice(0, -1).some((segment) => segment.length > 255)) + throw new OrchestrationError( + 'validation', + 'Archive folder names must be at most 255 characters' + ) + const name = entry.segments[entry.segments.length - 1] + const restored = restoreSimPageSourceBuffer(name, entry.buffer) + const content = restored?.buffer ?? entry.buffer + bytes += content.length + if (bytes > MAX_ARCHIVE_TOTAL_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + 'Extracted content exceeds the archive limit' + ) + } + entries.push( + Object.freeze({ + parentSegments: Object.freeze(entry.segments.slice(0, -1)), + content: await stageFileContent({ + owner: args.owner, + userId: args.userId, + name: restored?.name ?? name, + contentType: restored + ? SIM_PAGE_CONTENT_TYPE + : getMimeTypeFromExtension(getFileExtension(name)), + content, + signal: args.signal, + }), + }) + ) + } + args.signal.throwIfAborted() + return Object.freeze({ entries: Object.freeze(entries), bytes, plan }) + } catch (error) { + try { + await discardFileArchive({ entries }) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Archive staging and cleanup failed') + } + throw error + } +} + +/** Publishes the complete tree through canonical shared writers in the caller's transaction. */ +export async function commitFileArchiveInTx( + tx: DbTransaction, + args: { + owner: EditableFileOwner + userId: string + rootName: string + parentId: string | null + parentSegments: readonly string[] + staged: StagedFileArchive + secretProvenance: WorkspaceFileSecretProvenance + signal: AbortSignal + } +) { + args.signal.throwIfAborted() + if (!args.staged.entries.length) { + throw new OrchestrationError('validation', 'No files could be unzipped from this archive') + } + if ( + args.staged.entries.some((entry) => + entry.parentSegments.some((segment) => segment.length > 255) + ) + ) + throw new OrchestrationError( + 'validation', + 'Archive folder names must be at most 255 characters' + ) + const root = await createFileFolder( + { + owner: args.owner, + userId: args.userId, + name: args.rootName, + parentId: args.parentId, + exactName: false, + validateResolvedName: (name) => + args.staged.plan.validateRootFolderSegments([...args.parentSegments, name]), + }, + tx + ) + const folders = new Map([['[]', root.id]]) + const provenance: WorkspaceFileSecretProvenance = + args.secretProvenance.status === 'unrecorded' || + (args.secretProvenance.status === 'exact' && args.secretProvenance.entries.length === 0) + ? args.secretProvenance + : { status: 'unknown' } + for (const entry of args.staged.entries) { + args.signal.throwIfAborted() + let parentId = root.id + for (let depth = 0; depth < entry.parentSegments.length; depth++) { + const key = JSON.stringify(entry.parentSegments.slice(0, depth + 1)) + let folderId = folders.get(key) + if (!folderId) { + const created = await createFileFolder( + { + owner: args.owner, + userId: args.userId, + name: entry.parentSegments[depth], + parentId, + }, + tx + ) + folderId = created.id + folders.set(key, folderId) + } + parentId = folderId + } + await commitFileCreateInTx(tx, { + owner: args.owner, + userId: args.userId, + staged: entry.content, + folderId: parentId, + exactName: false, + secretProvenance: provenance, + }) + } + args.signal.throwIfAborted() + return { + folderId: root.id, + folderName: root.name, + folderDisplayPath: root.path, + extractedCount: args.staged.entries.length, + skippedCount: args.staged.plan.skipped, + } +} diff --git a/apps/sim/lib/workspace-files/copy-references.test.ts b/apps/sim/lib/workspace-files/copy-references.test.ts new file mode 100644 index 00000000000..35dcfd0043c --- /dev/null +++ b/apps/sim/lib/workspace-files/copy-references.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' +import { rewriteCopiedFileReferences } from '@/lib/workspace-files/copy-references' + +const workspaceOwner = { entityType: 'workspace', entityId: 'source-ws' } as const +const projectOwner = { entityType: 'project', entityId: 'target-project' } as const +const sourceKey = 'workspace/source-ws/123-photo.png' +const maps = { + sourceOwner: workspaceOwner, + destinationOwner: projectOwner, + fileIds: new Map([['photo-1', 'copied-photo']]), + fileKeys: new Map([[sourceKey, 'copied-photo']]), +} + +const projectBytes = '/api/projects/target-project/files/copied-photo/content' + +describe('selected copy references', () => { + it('preserves sentence punctuation while rewriting bare references', () => { + expect( + rewriteCopiedFileReferences('See /api/files/view/photo-1. Then sim:file/photo-1!', maps) + ).toBe(`See ${projectBytes}. Then sim:file/copied-photo!`) + }) + + it('rewrites selected neutral identities without touching other IDs or resources', () => { + const source = [ + '![photo](/api/files/view/photo-1)', + '[selected](sim:file/photo-1)', + '[other](sim:file/photo-10)', + '[table](sim:table/photo-1)', + '![not selected](/api/files/view/other)', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe( + [ + `![photo](${projectBytes})`, + '[selected](sim:file/copied-photo)', + '[other](sim:file/photo-10)', + '[table](sim:table/photo-1)', + '![not selected](/api/files/view/other)', + ].join('\n') + ) + }) + + it('normalizes selected raw and encoded storage URLs to destination bytes', () => { + const source = [ + `/api/files/serve/${sourceKey}`, + `/api/files/serve/s3/${encodeURIComponent(sourceKey)}?context=workspace`, + `/api/files/serve/blob/${encodeURIComponent(sourceKey)}`, + `/api/files/serve/gcs/${encodeURIComponent(sourceKey)}`, + ] + .map((url) => `![photo](${url})`) + .join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe( + Array.from({ length: 4 }, () => `![photo](${projectBytes})`).join('\n') + ) + }) + + it('requires the exact source owner prefix even if a foreign key is accidentally mapped', () => { + const foreign = 'workspace/source-ws-other/123-photo.png' + const source = `![photo](/api/files/serve/${encodeURIComponent(foreign)})` + expect( + rewriteCopiedFileReferences(source, { + ...maps, + fileKeys: new Map([[foreign, 'copied-photo']]), + }) + ).toBe(source) + }) + + it('moves both workspace-qualified identities to the destination Project and keeps fragments', () => { + const source = + '[app](/workspace/source-ws/files/photo-1#caption) [chip](sim:file/photo-1?workspace=source-ws#caption)' + expect(rewriteCopiedFileReferences(source, maps)).toBe( + '[app](/projects/target-project/files/copied-photo#caption) [chip](sim:file/copied-photo?project=target-project#caption)' + ) + }) + + it('moves Project-qualified identities and bytes to a workspace', () => { + const source = [ + '[app](/projects/target-project/files/photo-1)', + '[chip](sim:file/photo-1?project=target-project)', + '![photo](/api/projects/target-project/files/photo-1/content)', + '![key](/api/files/serve/project%2Ftarget-project%2F123-photo.png)', + ].join('\n') + expect( + rewriteCopiedFileReferences(source, { + ...maps, + sourceOwner: projectOwner, + destinationOwner: workspaceOwner, + fileKeys: new Map([['project/target-project/123-photo.png', 'copied-photo']]), + }) + ).toBe( + [ + '[app](/workspace/source-ws/files/copied-photo)', + '[chip](sim:file/copied-photo?workspace=source-ws)', + '![photo](/api/files/view/copied-photo)', + '![key](/api/files/view/copied-photo)', + ].join('\n') + ) + }) + + it('preserves foreign or ambiguous ownership and unsupported file paths', () => { + const source = [ + 'sim:file/photo-1?workspace=other', + 'sim:file/photo-1?project=target-project', + 'sim:file/photo-1?workspace=source-ws&project=target-project', + 'sim:file/photo-1?workspace=source-ws&workspace=source-ws', + '/workspace/other/files/photo-1', + '/projects/target-project/files/photo-1', + '/api/projects/target-project/files/photo-1/content', + '/api/files/view/photo-1/extra', + '/api/files/view/photo-1?project=other', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe(source) + }) + + it('does not turn absolute or protocol-relative URLs into local destination references', () => { + const source = [ + 'https://other.test/api/files/view/photo-1', + '//other.test/api/files/view/photo-1', + 'https://other.test/workspace/source-ws/files/photo-1', + 'https://other.test/?next=/api/files/view/photo-1', + 'https://other.test/a(/api/files/view/photo-1)', + 'prefixsim:file/photo-1', + 'x/api/files/view/photo-1', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe(source) + }) + + it('decodes once while preserving malformed and twice-encoded references', () => { + const source = [ + '/api/files/view/photo%2D1', + '/api/files/view/photo%252D1', + '/api/files/view/%E0%A4%A', + '/workspace/source%2Dws/files/photo%2D1', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe( + [ + projectBytes, + '/api/files/view/photo%252D1', + '/api/files/view/%E0%A4%A', + '/projects/target-project/files/copied-photo', + ].join('\n') + ) + }) + + it('applies each selected mapping once without cascading into another selected identity', () => { + expect( + rewriteCopiedFileReferences('sim:file/photo-1 sim:file/copied-photo', { + ...maps, + fileIds: new Map([ + ['photo-1', 'copied-photo'], + ['copied-photo', 'third-photo'], + ]), + }) + ).toBe('sim:file/copied-photo sim:file/third-photo') + }) + + it('rewrites only selected Office helper and input-path identities', () => { + const source = [ + "const image = await getFileBase64('photo-1')", + 'await addImage(slide, "photo-1", options)', + "await addImage('photo-1', options)", + "drawImage(page, 'photo-1', opts)", + "Doc.open(input_path('photo-1'))", + "fs.readFileSync('/home/user/inputs/photo-1')", + "getFileBase64('unselected')", + 'getFileBase64(variable)', + "slide.addText('photo-1')", + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps, 'javascript')).toBe( + [ + "const image = await getFileBase64('copied-photo')", + 'await addImage(slide, "copied-photo", options)', + "await addImage('copied-photo', options)", + "drawImage(page, 'copied-photo', opts)", + "Doc.open(input_path('copied-photo'))", + "fs.readFileSync('/home/user/inputs/copied-photo')", + "getFileBase64('unselected')", + 'getFileBase64(variable)', + "slide.addText('photo-1')", + ].join('\n') + ) + }) + + it.each(['javascript', 'python'] as const)( + 'copies unfinished %s document source without partial reference rewrites', + (language) => { + const source = [ + 'link = "/api/files/view/photo-1"', + 'image = getFileBase64("photo-1")', + 'unfinished = "unterminated', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps, language)).toBe(source) + } + ) +}) diff --git a/apps/sim/lib/workspace-files/copy-references.ts b/apps/sim/lib/workspace-files/copy-references.ts new file mode 100644 index 00000000000..08ac7719cd4 --- /dev/null +++ b/apps/sim/lib/workspace-files/copy-references.ts @@ -0,0 +1,187 @@ +import { + type DocumentSourceLanguage, + iterateDocumentFileReferences, +} from '@/lib/uploads/documents/references' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +interface CopiedFileReferenceMaps { + sourceOwner: EditableFileOwner + destinationOwner: EditableFileOwner + fileIds: ReadonlyMap + fileKeys: ReadonlyMap +} + +interface SourceReplacement { + start: number + end: number + value: string +} + +const FILE_REFERENCE_RE = + /(^|[\s("'<>`])((?:[A-Za-z][A-Za-z0-9+.-]*:\/\/|\/\/|sim:file\/|\/(?:api\/(?:files\/(?:view|serve)\/|projects\/)|workspace\/|projects\/))[^\s)"'<>`]+)/g + +function absoluteReferenceEnd(content: string, start: number): number { + let parentheses = 0 + let end = start + for (; end < content.length; end++) { + const character = content[end] + if (/[\s"'<>`]/.test(character)) break + if (character === '(') parentheses++ + if (character === ')') { + if (parentheses === 0) break + parentheses-- + } + } + return end +} + +function decodedIdentity(value: string): string | undefined { + try { + const decoded = decodeURIComponent(value) + return /^[A-Za-z0-9_-]+$/.test(decoded) ? decoded : undefined + } catch { + return undefined + } +} + +function destinationBytes(owner: EditableFileOwner, fileId: string): string { + return owner.entityType === 'project' + ? `/api/projects/${encodeURIComponent(owner.entityId)}/files/${encodeURIComponent(fileId)}/content` + : `/api/files/view/${encodeURIComponent(fileId)}` +} + +function destinationPage(owner: EditableFileOwner, fileId: string): string { + const namespace = owner.entityType === 'project' ? 'projects' : 'workspace' + return `/${namespace}/${encodeURIComponent(owner.entityId)}/files/${encodeURIComponent(fileId)}` +} + +function matchesOwner(owner: EditableFileOwner, entityType: string, entityId: string): boolean { + return owner.entityType === entityType && owner.entityId === decodedIdentity(entityId) +} + +function rewriteFileReference(reference: string, maps: CopiedFileReferenceMaps): string | null { + const hashIndex = reference.indexOf('#') + const fragment = hashIndex < 0 ? '' : reference.slice(hashIndex) + const withoutFragment = hashIndex < 0 ? reference : reference.slice(0, hashIndex) + const queryIndex = withoutFragment.indexOf('?') + const pathname = queryIndex < 0 ? withoutFragment : withoutFragment.slice(0, queryIndex) + const query = queryIndex < 0 ? null : new URLSearchParams(withoutFragment.slice(queryIndex + 1)) + const mappedId = (spelledId: string) => { + const fileId = decodedIdentity(spelledId) + return fileId === undefined ? undefined : maps.fileIds.get(fileId) + } + + const simLink = /^sim:file\/([^/]+)$/.exec(pathname) + if (simLink) { + const fileId = mappedId(simLink[1]) + if (!fileId) return null + if (query === null) return `sim:file/${encodeURIComponent(fileId)}${fragment}` + const entries = [...query] + const entry = entries[0] + if ( + entries.length !== 1 || + !entry || + entry[0] !== maps.sourceOwner.entityType || + entry[1] !== maps.sourceOwner.entityId + ) { + return null + } + return `sim:file/${encodeURIComponent(fileId)}?${maps.destinationOwner.entityType}=${encodeURIComponent(maps.destinationOwner.entityId)}${fragment}` + } + + const serve = /^\/api\/files\/serve\/(?:s3\/|blob\/|gcs\/)?(.+)$/.exec(pathname) + if (serve) { + if (query !== null) { + const entries = [...query] + const entry = entries[0] + if ( + entries.length !== 1 || + !entry || + entry[0] !== 'context' || + entry[1] !== maps.sourceOwner.entityType + ) { + return null + } + } + let key: string + try { + key = decodeURIComponent(serve[1]) + } catch { + return null + } + if (!key.startsWith(`${maps.sourceOwner.entityType}/${maps.sourceOwner.entityId}/`)) return null + const fileId = maps.fileKeys.get(key) + return fileId ? destinationBytes(maps.destinationOwner, fileId) + fragment : null + } + + if (query !== null) return null + const view = /^\/api\/files\/view\/([^/]+)$/.exec(pathname) + if (view) { + const fileId = mappedId(view[1]) + return fileId ? destinationBytes(maps.destinationOwner, fileId) + fragment : null + } + const page = /^\/(workspace|projects)\/([^/]+)\/files\/([^/]+)$/.exec(pathname) + if (page) { + const entityType = page[1] === 'projects' ? 'project' : 'workspace' + if (!matchesOwner(maps.sourceOwner, entityType, page[2])) return null + const fileId = mappedId(page[3]) + return fileId ? destinationPage(maps.destinationOwner, fileId) + fragment : null + } + const projectContent = /^\/api\/projects\/([^/]+)\/files\/([^/]+)\/content$/.exec(pathname) + if (projectContent) { + if (!matchesOwner(maps.sourceOwner, 'project', projectContent[1])) return null + const fileId = mappedId(projectContent[2]) + return fileId ? destinationBytes(maps.destinationOwner, fileId) + fragment : null + } + return null +} + +/** Repoints only the canonical selected identities; it neither discovers nor copies dependencies. */ +export function rewriteCopiedFileReferences( + content: string, + maps: CopiedFileReferenceMaps, + language: DocumentSourceLanguage | null = null +): string { + if (!content || (maps.fileIds.size === 0 && maps.fileKeys.size === 0)) return content + const replacements: SourceReplacement[] = [] + let protectedEnd = 0 + for (const match of content.matchAll(FILE_REFERENCE_RE)) { + const reference = match[2] + const start = match.index + match[1].length + if (start < protectedEnd) continue + if (/^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/|\/\/)/.test(reference)) { + protectedEnd = absoluteReferenceEnd(content, start) + continue + } + let value = rewriteFileReference(reference, maps) + if (value === null && !language && /[.,;:!?]+$/.test(reference)) { + const trimmed = reference.replace(/[.,;:!?]+$/, '') + const rewritten = rewriteFileReference(trimmed, maps) + if (rewritten !== null) value = rewritten + reference.slice(trimmed.length) + } + if (value === null) continue + replacements.push({ start, end: start + reference.length, value }) + } + if (language) { + try { + for (const { fileId, start, end } of iterateDocumentFileReferences(content, language)) { + const value = maps.fileIds.get(fileId) + if (value !== undefined) replacements.push({ start, end, value }) + } + } catch (error) { + if (error instanceof DocCompileUserError) return content + throw error + } + } + replacements.sort((left, right) => left.start - right.start) + const chunks: string[] = [] + let offset = 0 + for (const { start, end, value } of replacements) { + chunks.push(content.slice(offset, start), value) + offset = end + } + chunks.push(content.slice(offset)) + return chunks.join('') +} + +import { DocCompileUserError } from '@/lib/uploads/documents/compile-error' diff --git a/apps/sim/lib/workspace-files/copy.ts b/apps/sim/lib/workspace-files/copy.ts new file mode 100644 index 00000000000..f009d0321d8 --- /dev/null +++ b/apps/sim/lib/workspace-files/copy.ts @@ -0,0 +1,228 @@ +import { folder, type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, asc, inArray, isNull, or } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + assertFileFolderTarget, + buildWorkspaceFileFolderPathMap, + createFileFolderRow, + listFileFolders, +} from '@/lib/uploads/contexts/workspace/file-folder-manager' +import { + commitFileCreateInTx, + mapFileRecord, + type OwnedFileRecord, + type PlannedFileIdentity, + type StagedFileContent, +} from '@/lib/uploads/contexts/workspace/file-manager' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import { getWorkspaceFileSize, MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS } from '@/lib/workspace-files/limits' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +interface CopySelection { + owner: EditableFileOwner + fileIds: readonly string[] + folderIds: readonly string[] +} + +interface CopyFolder { + id: string + name: string + parentId: string | null + updatedAt: Date +} + +interface FileCopySnapshot { + owner: EditableFileOwner + files: WorkspaceFileRow[] + folders: CopyFolder[] + provenance: Map + bytes: number +} + +export interface CopiedFileItems { + files: OwnedFileRecord[] + folders: Awaited> +} + +function requireBoundedSelection(size: number) { + if (size > MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS) { + throw new OrchestrationError('validation', 'Copy expands beyond the file selection limit') + } +} + +/** Captures only explicit source selections and their bounded active descendants. */ +export async function snapshotFileCopyInTx( + tx: DbTransaction, + source: CopySelection +): Promise { + if (!source.fileIds.length && !source.folderIds.length) { + throw new OrchestrationError('validation', 'Copy requires a source selection') + } + const loadFolders = (ids: readonly string[], descendants: boolean, limit: number) => + tx + .select({ + id: folder.id, + name: folder.name, + parentId: folder.parentId, + updatedAt: folder.updatedAt, + }) + .from(folder) + .where( + and( + fileFolderOwnerCondition(source.owner), + isNull(folder.deletedAt), + inArray(descendants ? folder.parentId : folder.id, [...ids]) + ) + ) + .orderBy(asc(folder.id)) + .limit(limit) + const candidates = source.folderIds.length + ? await loadFolders(source.folderIds, false, MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + : [] + const selectedFolders = new Set(candidates.map((row) => row.id)) + if (source.folderIds.some((id) => !selectedFolders.has(id))) + throw new OrchestrationError('not_found', 'Source folder not found') + let frontier = [...selectedFolders] + while (frontier.length) { + requireBoundedSelection(selectedFolders.size) + const children = await loadFolders(frontier, true, MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + frontier = [] + for (const child of children) { + if (selectedFolders.has(child.id)) continue + selectedFolders.add(child.id) + candidates.push(child) + frontier.push(child.id) + } + requireBoundedSelection(selectedFolders.size) + } + const files = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(source.owner), + isNull(workspaceFiles.deletedAt), + or( + source.fileIds.length ? inArray(workspaceFiles.id, [...source.fileIds]) : undefined, + selectedFolders.size ? inArray(workspaceFiles.folderId, [...selectedFolders]) : undefined + ) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + .for('share') + requireBoundedSelection(files.length + selectedFolders.size) + const fileIds = new Set(files.map((row) => row.id)) + if (source.fileIds.some((id) => !fileIds.has(id))) { + throw new OrchestrationError('not_found', 'Source file not found') + } + const bytes = files.reduce((sum, row) => sum + getWorkspaceFileSize(row), 0) + if (!Number.isSafeInteger(bytes) || bytes > MAX_BUFFERED_TRANSFER_BYTES) { + throw new OrchestrationError('payload_too_large', 'Copy exceeds the buffered transfer limit') + } + const folders = candidates.filter((row) => selectedFolders.has(row.id)) + const provenance = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, files) + return { owner: source.owner, files, folders, provenance, bytes } +} + +function snapshotIdentity(snapshot: FileCopySnapshot) { + return JSON.stringify({ + owner: snapshot.owner, + folders: snapshot.folders, + files: snapshot.files.map((file) => ({ + id: file.id, + key: file.key, + name: file.originalName, + folderId: file.folderId, + size: getWorkspaceFileSize(file), + type: file.contentType, + updatedAt: file.updatedAt, + contentUpdatedAt: file.contentUpdatedAt, + provenance: snapshot.provenance.get(file.id), + })), + }) +} + +/** Refuses changed bytes, metadata, classification, or recursive membership after external staging. */ +export function requireUnchangedFileCopy(prepared: FileCopySnapshot, current: FileCopySnapshot) { + if (snapshotIdentity(prepared) !== snapshotIdentity(current)) { + throw new OrchestrationError('conflict', 'Source selection changed during copy; retry') + } +} + +/** Commits a fresh tree and current heads through the shared identity/provenance writers. */ +export async function commitFileCopyInTx( + tx: DbTransaction, + args: { + snapshot: FileCopySnapshot + destination: { owner: EditableFileOwner; folderId: string | null } + userId: string + staged: ReadonlyMap + identities: ReadonlyMap + } +): Promise { + const { snapshot, destination, userId } = args + const targetFolderId = await assertFileFolderTarget(destination.owner, destination.folderId, tx) + const pending = new Map(snapshot.folders.map((row) => [row.id, row])) + const mapped = new Map() + while (pending.size) { + let progressed = false + for (const [id, source] of pending) { + if (source.parentId && pending.has(source.parentId)) continue + const created = await createFileFolderRow( + { + owner: destination.owner, + userId, + name: source.name, + parentId: source.parentId + ? (mapped.get(source.parentId) ?? targetFolderId) + : targetFolderId, + exactName: false, + }, + tx + ) + mapped.set(id, created.id) + pending.delete(id) + progressed = true + } + if (!progressed) + throw new OrchestrationError('conflict', 'Source folder hierarchy contains a cycle') + } + const createdFiles: WorkspaceFileRow[] = [] + for (const source of snapshot.files) { + const staged = args.staged.get(source.id) + const secretProvenance = snapshot.provenance.get(source.id) + const identity = args.identities.get(source.id) + if (!staged || !secretProvenance || !identity) + throw new Error('Copied file staging is incomplete') + createdFiles.push( + await commitFileCreateInTx(tx, { + owner: destination.owner, + staged, + identity, + userId, + folderId: source.folderId + ? (mapped.get(source.folderId) ?? targetFolderId) + : targetFolderId, + secretProvenance, + }) + ) + } + const destinationFolders = await listFileFolders(destination.owner, undefined, tx) + const paths = buildWorkspaceFileFolderPathMap(destinationFolders) + const folderById = new Map(destinationFolders.map((folder) => [folder.id, folder])) + return { + files: createdFiles.map((file) => mapFileRecord(file, destination.owner, paths)), + folders: [...mapped.values()].map((id) => { + const folder = folderById.get(id) + if (!folder) throw new Error('Copied folder is missing from the destination') + return folder + }), + } +} diff --git a/apps/sim/lib/workspace-files/delivery.integration.ts b/apps/sim/lib/workspace-files/delivery.integration.ts new file mode 100644 index 00000000000..c659027629f --- /dev/null +++ b/apps/sim/lib/workspace-files/delivery.integration.ts @@ -0,0 +1,310 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { + changelogRelease, + permissions, + user, + userStats, + workflowTest, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { + createDelegatedPrincipal, + createSessionPrincipal, +} from '@sim/testing/factories/principal.factory' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) + +import { type ContentProvenance, observeContentDelivery } from '@/lib/core/application' +import { + createOwnedWorkspaceFile, + uploadWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/file-manager' +import { markWorkspaceFileSecretProvenanceUnknown } from '@/lib/uploads/contexts/workspace/file-secret-provenance' +import * as storage from '@/lib/uploads/core/storage-service' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' +import { createFileReadReceipt, recheckFileReadReceipt } from '@/lib/workspace-files/read-receipt' + +const uploadRoot = mkdtempSync(join(tmpdir(), 'sim-delivery-test-')) +setUploadDirServer(uploadRoot) +const fixtures: { ownerId: string; editorId: string; workspaceId: string }[] = [] +const checks: { name: string; status: string; durationMs: number; error?: string }[] = [] +beforeEach(() => vi.restoreAllMocks()) +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + name: 'Delivery fixture', + email: `${id}@delivery.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(userStats).values({ id: generateId(), userId: ownerId }) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + name: 'Delivery', + workspaceMode: 'personal', + }) + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + fixtures.push({ ownerId, editorId, workspaceId }) + return { ownerId, editorId, workspaceId, principal: createSessionPrincipal({ userId: editorId }) } +} +describe('Workspace delivery against current PostgreSQL authority and local storage', () => { + for (const mutation of ['membership', 'revision', 'deletion'] as const) { + check( + `workspace inline delivery cancels its opened stream after ${mutation} changes`, + async () => { + const f = await fixture() + const file = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.alloc(1024 * 1024, 42), + 'image.png', + 'image/png', + { notifyWorkspaceChange: false } + ) + const open = storage.downloadFileStream + let source: Awaited> | undefined + vi.spyOn(storage, 'downloadFileStream').mockImplementationOnce(async (options) => { + source = await open(options) + if (mutation === 'membership') { + await db + .delete(permissions) + .where( + and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId)) + ) + } else { + await db + .update(workspaceFiles) + .set( + mutation === 'deletion' + ? { deletedAt: new Date() } + : { contentUpdatedAt: new Date(Date.now() + 1000) } + ) + .where(eq(workspaceFiles.id, file.id)) + } + return source + }) + await expect( + readWorkspaceInlineFile.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, fileId: file.id }, + }) + ).rejects.toBeInstanceOf(Error) + expect(source?.destroyed).toBe(true) + } + ) + } +}) + +describe('Rendered input receipts preserve owned-file eligibility', () => { + for (const context of ['test', 'changelog'] as const) { + for (const mutation of ['owner-deletion', 'foreign-workspace', 'revision', 'key'] as const) { + check(`${context} input receipt accepts its live owner and rejects ${mutation}`, async () => { + const f = await fixture() + const foreign = mutation === 'foreign-workspace' ? await fixture() : undefined + const body = await createOwnedWorkspaceFile({ + context, + workspaceId: f.workspaceId, + userId: f.ownerId, + content: 'owned delivery input', + contentType: 'text/plain', + fileName: (id) => `${id}.txt`, + insertOwner: async (tx, bodyFileId) => { + if (context === 'test') + await tx.insert(workflowTest).values({ + id: generateId(), + workspaceId: f.workspaceId, + name: generateId(), + title: 'Delivery input', + bodyFileId, + sourceHash: 'fixture', + }) + else + await tx.insert(changelogRelease).values({ + id: generateId(), + workspaceId: f.workspaceId, + title: 'Delivery input', + bodyFileId, + versionMajor: 1, + versionMinor: 0, + versionPatch: 0, + bumpReason: 'fixture', + }) + }, + }) + const [file] = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.id, body.fileId)) + const receipt = createFileReadReceipt( + { entityType: 'workspace', entityId: f.workspaceId }, + [file] + ) + await expect( + db.transaction((tx) => recheckFileReadReceipt(tx, receipt)) + ).resolves.toBeDefined() + if (mutation === 'owner-deletion') { + if (context === 'test') + await db + .update(workflowTest) + .set({ deletedAt: new Date() }) + .where(eq(workflowTest.bodyFileId, body.fileId)) + else await db.delete(changelogRelease).where(eq(changelogRelease.bodyFileId, body.fileId)) + } else if (mutation === 'foreign-workspace') { + if (!foreign) throw new Error('Foreign workspace fixture is missing') + const foreignReceipt = createFileReadReceipt( + { entityType: 'workspace', entityId: foreign.workspaceId }, + [file] + ) + await expect( + db.transaction((tx) => recheckFileReadReceipt(tx, foreignReceipt)) + ).rejects.toThrow('A file changed') + return + } else { + await db + .update(workspaceFiles) + .set( + mutation === 'revision' + ? { contentUpdatedAt: new Date(file.contentUpdatedAt.getTime() + 1000) } + : { key: `${file.key}.changed` } + ) + .where(eq(workspaceFiles.id, file.id)) + } + await expect(db.transaction((tx) => recheckFileReadReceipt(tx, receipt))).rejects.toThrow( + 'A file changed' + ) + }) + } + } +}) + +check( + 'downloads above the buffering ceiling stream incrementally and release storage on cancellation', + async () => { + const f = await fixture() + const file = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('streamed'), + 'large.bin', + 'application/octet-stream', + { notifyWorkspaceChange: false } + ) + const size = MAX_BUFFERED_TRANSFER_BYTES + 1024 + await truncate(join(uploadRoot, file.key), size) + await db.update(workspaceFiles).set({ sizeBytes: size }).where(eq(workspaceFiles.id, file.id)) + const open = storage.downloadFileStream + let source: Awaited> | undefined + vi.spyOn(storage, 'downloadFileStream').mockImplementationOnce(async (options) => { + source = await open(options) + return source + }) + const result = await downloadWorkspaceFileStream.execute({ + principal: f.principal, + input: { fileId: file.id }, + }) + expect(result.contentLength).toBe(size) + const reader = result.stream.getReader() + const first = await reader.read() + expect(first.done).toBe(false) + expect(first.value?.length).toBeLessThan(size) + await reader.cancel() + expect(source?.destroyed).toBe(true) + } +) + +check('same-revision provenance downgrade reaches the content admission gate', async () => { + const f = await fixture() + const file = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('classification race'), + 'race.txt', + 'text/plain', + { notifyWorkspaceChange: false, secretProvenance: { status: 'exact', entries: [] } } + ) + const open = storage.downloadFileStream + vi.spyOn(storage, 'downloadFileStream').mockImplementationOnce(async (options) => { + const source = await open(options) + await markWorkspaceFileSecretProvenanceUnknown(f.workspaceId, [file.id]) + return source + }) + const observed: ContentProvenance[] = [] + const result = await observeContentDelivery( + { + async admit(sources) { + observed.push(...sources.map((source) => source.provenance)) + return sources.map(() => true) + }, + }, + () => + downloadWorkspaceFileStream.execute({ + principal: createDelegatedPrincipal({ + subjectUserId: f.editorId, + workspaceId: f.workspaceId, + audience: 'sim:workspace-files', + }), + input: { fileId: file.id }, + }) + ) + await result.stream.cancel() + const [current] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.id, file.id)) + expect(current.contentUpdatedAt).toEqual(file.contentUpdatedAt) + expect(observed.at(-1)).toEqual({ status: 'unknown' }) +}) + +afterAll(async () => { + for (const fixture of fixtures) { + await deleteWorkspaceFixture(db, eq(workspace.id, fixture.workspaceId)) + await db.delete(user).where(inArray(user.id, [fixture.ownerId, fixture.editorId])) + } + await rm(uploadRoot, { recursive: true, force: true }) + const report = process.env.FILE_DELIVERY_INTEGRATION_REPORT_PATH + if (report) { + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + } +}) diff --git a/apps/sim/lib/workspace-files/download-selection.ts b/apps/sim/lib/workspace-files/download-selection.ts new file mode 100644 index 00000000000..fc0e576ea52 --- /dev/null +++ b/apps/sim/lib/workspace-files/download-selection.ts @@ -0,0 +1,64 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { parseFolderPath } from '@/lib/folders/paths' +import { + collectDescendantFolderIdsFrom, + type FolderNode, + indexFolderChildren, +} from '@/lib/folders/subtree' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +export interface FileDownloadSelection { + fileIds: readonly string[] + folderIds: readonly string[] + folderPaths?: readonly string[] +} + +/** Normalizes the bounded explicit selection before either owner's directory walk. */ +export function normalizeFileDownloadSelection(input: FileDownloadSelection) { + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + const folderPaths = [...new Set(input.folderPaths ?? [])] + if (fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS) + throw new OrchestrationError( + 'validation', + `Too many file IDs selected. Select ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} or fewer files.` + ) + if (folderIds.length + folderPaths.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS) + throw new OrchestrationError( + 'validation', + `Too many folders selected. Select ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} or fewer folders.` + ) + if (!fileIds.length && !folderIds.length && !folderPaths.length) + throw new OrchestrationError('validation', 'No files selected for download') + return { fileIds, folderIds, folderPaths } +} + +/** Expands canonical parent relationships, never textual path prefixes. */ +export function expandFileDownloadFolders( + input: FileDownloadSelection, + folders: readonly FolderNode[], + displayPaths: ReadonlyMap +): Set { + const known = new Set(folders.map((folder) => folder.id)) + if (input.folderIds.some((id) => !known.has(id))) + throw new OrchestrationError('not_found', 'Folder not found') + const selected = new Set(input.folderIds) + if (input.folderPaths?.length) { + const byPath = new Map() + for (const folder of folders) { + const path = displayPaths.get(folder.id) + if (path) byPath.set(parseWorkspaceFileFolderDisplayPath(path).join('\0'), folder.id) + } + for (const path of input.folderPaths) { + const id = byPath.get(parseFolderPath(path).join('\0')) + if (!id) throw new OrchestrationError('validation', `Folder not found: ${path}`) + selected.add(id) + } + } + const children = indexFolderChildren(folders) + for (const id of [...selected]) { + for (const descendant of collectDescendantFolderIdsFrom(children, id)) selected.add(descendant) + } + return selected +} diff --git a/apps/sim/lib/workspace-files/extraction-lease.ts b/apps/sim/lib/workspace-files/extraction-lease.ts new file mode 100644 index 00000000000..58271ee106f --- /dev/null +++ b/apps/sim/lib/workspace-files/extraction-lease.ts @@ -0,0 +1,79 @@ +import { idempotencyKey } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, sql } from 'drizzle-orm' +import { IdempotencyService } from '@/lib/core/idempotency/service' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +const logger = createLogger('FileArchiveLease') +const leases = new IdempotencyService({ + namespace: 'file-archive', + ttlSeconds: 6 * 60, + forceStorage: 'database', +}) + +/** Cooperative work budget leaves time for compensation before the lease expires. */ +export const FILE_EXTRACTION_BUDGET_MS = 180_000 + +interface FileExtractionLease { + readonly key: string + readonly token: string +} + +/** Holds an exact, token-fenced database lease without memoizing any extraction result. */ +export async function withFileExtractionLease( + owner: EditableFileOwner, + fileId: string, + action: (lease: FileExtractionLease) => Promise +): Promise { + const claim = await leases.atomicallyClaim( + 'extract', + `${owner.entityType}:${owner.entityId}:${fileId}` + ) + if (!claim.claimed) + throw new OrchestrationError('conflict', 'This archive is already being unzipped') + if (!claim.claimToken) throw new Error('Archive extraction lease is missing its fencing token') + try { + return await action(Object.freeze({ key: claim.normalizedKey, token: claim.claimToken })) + } finally { + await leases + .release(claim.normalizedKey, claim.storageMethod, claim.claimToken) + .catch((error) => { + logger.warn('Failed to release archive extraction lease', { + owner, + fileId, + error: getErrorMessage(error), + }) + }) + } +} + +/** Serializes publication with claim replacement and refuses expired or superseded work. */ +export async function requireFileExtractionLeaseInTx( + tx: DbTransaction, + lease: FileExtractionLease +) { + const [claim] = await tx + .select({ result: idempotencyKey.result }) + .from(idempotencyKey) + .where( + and( + eq(idempotencyKey.key, lease.key), + sql`${idempotencyKey.result}->>'claimToken' = ${lease.token}` + ) + ) + .for('update') + .limit(1) + const result = claim?.result + if ( + !isRecordLike(result) || + result.status !== 'in-progress' || + typeof result.inProgressExpiresAt !== 'number' || + result.inProgressExpiresAt <= Date.now() + ) { + throw new OrchestrationError('conflict', 'Archive extraction lease expired; retry') + } +} diff --git a/apps/sim/lib/workspace-files/limits.ts b/apps/sim/lib/workspace-files/limits.ts index c1b80bad120..d5b89b45483 100644 --- a/apps/sim/lib/workspace-files/limits.ts +++ b/apps/sim/lib/workspace-files/limits.ts @@ -9,3 +9,4 @@ export const MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS = 5_000 * validate, resolve, and only then fail β€” and so the two ceilings cannot drift. */ export const MAX_ZIP_DOWNLOAD_FILES = 100 +export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 diff --git a/apps/sim/lib/workspace-files/page-compile.test.ts b/apps/sim/lib/workspace-files/page-compile.test.ts index b9eae33f957..3cc407fb9b8 100644 --- a/apps/sim/lib/workspace-files/page-compile.test.ts +++ b/apps/sim/lib/workspace-files/page-compile.test.ts @@ -62,6 +62,56 @@ describe('compileSimPage', () => { expect(html).toContain('src="/api/files/view/img9"') }) + it('resolves a qualified Project file without treating its owner query as part of the ID', () => { + const html = compileSimPage( + '---\ntitle: T\n---\n![diagram](sim:file/img%2D9?project=project%2Done)\n[open](sim:file/img%2D9?project=project%2Done#details)', + { workspaceId: 'ambient-workspace', baseUrl: 'https://sim.example/' } + ) + expect(html).toContain('src="https://sim.example/api/projects/project-one/files/img-9/content"') + expect(html).toContain( + 'href="https://sim.example/projects/project-one/files/img-9#details" data-sim-link=""' + ) + }) + + it('retains an explicit workspace owner when a Project page contains a workspace file link', () => { + const html = compileSimPage( + '---\ntitle: T\n---\n[open](sim:file/img9?workspace=source-workspace)', + { projectId: 'ambient-project' } + ) + expect(html).toContain('href="/workspace/source-workspace/files/img9" data-sim-link=""') + expect(html).not.toContain('/api/projects/ambient-project/files/img9') + }) + + it('rejects cross-owner images in a Project page before compiling byte URLs', () => { + for (const owner of ['workspace=source-workspace', 'project=other-project']) { + expect(() => + compileSimPage(`---\ntitle: T\n---\n![diagram](sim:file/img9?${owner})`, { + projectId: 'ambient-project', + }) + ).toThrow('Project pages can only embed files owned by the same Project') + } + }) + + it('leaves ambiguous and malformed file ownership inert instead of inventing a byte URL', () => { + const links = [ + 'sim:file/img9?project=one&workspace=two', + 'sim:file/img9?project=one&project=two', + 'sim:file/img9?organization=one', + 'sim:file/img9?project=', + 'sim:file/img9?project=one%2Ftwo', + 'sim:file/img%252F9?project=one', + 'sim:file/%E0%A4%A?project=one', + ] + const html = compileSimPage( + `---\ntitle: T\n---\n${links.map((link) => `[open](${link}) ![image](${link})`).join('\n')}`, + { projectId: 'ambient-project' } + ) + expect(html).not.toContain('/api/projects/') + expect(html).not.toContain('/api/files/view/') + expect(html.match(/href="sim:file\//g)).toHaveLength(links.length) + expect(html.match(/src="sim:file\//g)).toHaveLength(links.length) + }) + it('escapes html in yaml-derived values', () => { const html = compileSimPage( '---\ntitle: T\n---\n```sim:kv\n- { key: "'], + ['code.js', 'application/javascript', 'globalThis.deliveryExecuted=true'], + ['mismatch.txt', 'text/html', ''], + ]) { + const file = await create('project', name, type, content) + const path = `${prefix}/${required(file.id)}/content` + const response = await request(path) + assert.equal(response.status, 200) + assert.equal(response.headers.get('cache-control'), 'private, no-store') + assert.equal(response.headers.get('x-content-type-options'), 'nosniff') + if (name.endsWith('.svg')) + assert.match(response.headers.get('content-security-policy') ?? '', /sandbox/) + if (name.endsWith('.html') || name.endsWith('.js')) + assert.match(response.headers.get('content-disposition') ?? '', /^attachment;/) + if (!name.endsWith('.svg')) + assert.match(response.headers.get('content-security-policy') ?? '', /default-src/) + const download = await request(`${prefix}/download?fileIds=${required(file.id)}`) + assert.equal(download.status, 200) + assert.match(download.headers.get('content-disposition') ?? '', /^attachment;/) + assert.match(download.headers.get('content-security-policy') ?? '', /default-src/) + const archive = await JSZip.loadAsync(await download.arrayBuffer()) + assert.equal(await archive.file(required(file.name))?.async('string'), content) + const head = await request(path, { method: 'HEAD' }) + assert.equal(head.status, 200) + assert.equal(await head.text(), '') + assert.equal(head.headers.get('cache-control'), 'private, no-store') + const denied = await request(path, { method: 'HEAD' }, true) + assert.equal(denied.status, 401) + assert.equal(denied.headers.get('content-disposition'), null) + assert.match(denied.headers.get('content-security-policy') ?? '', /default-src/) + const bypass = await request(`/api/files/serve/${required(file.key)}`) + assert.equal(bypass.status, 404) + } + const generated = await create( + 'project', + 'head.pdf', + 'text/x-pdflibjs', + 'throw new Error("HEAD must never execute this source")' + ) + const head = await request(`${prefix}/${required(generated.id)}/artifact`, { method: 'HEAD' }) + assert.equal(head.status, 200) + } +) +await check( + 'v2 Project SVG delivery and current, retained, missing and generated HEAD admission', + async () => { + const source = '' + const file = await create('project', 'v2-sandbox.svg', 'image/svg+xml', source) + const detail = `/api/v2/projects/${projectId}/files/${required(file.id)}` + const headers = { 'X-API-Key': personalKey } + const response = await request(`${detail}/content`, { headers }, true) + assert.equal(response.status, 200) + assert.equal(await response.text(), source) + assert.match(response.headers.get('content-security-policy') ?? '', /sandbox/) + assert.equal(response.headers.get('cache-control'), 'private, no-store') + const metadataResponse = await request(`${detail}/metadata`, { headers }, true) + assert.equal(metadataResponse.status, 200) + const metadata = toRecord(toRecord(await metadataResponse.json()).data) + const updated = await request( + `${detail}/content`, + { + method: 'PUT', + headers, + body: JSON.stringify({ + content: `${source}\n`, + encoding: 'utf-8', + expectedRevision: required(metadata.revision), + }), + }, + true + ) + assert.equal(updated.status, 200) + for (const suffix of ['/content', '/versions/1/content']) { + const head = await request(`${detail}${suffix}`, { method: 'HEAD', headers }, true) + assert.equal(head.status, 200) + assert.equal(await head.text(), '') + assert.equal(head.headers.get('cache-control'), 'private, no-store') + const denied = await request(`${detail}${suffix}`, { method: 'HEAD' }, true) + assert.equal(denied.status, 401) + assert.equal(denied.headers.get('content-disposition'), null) + } + const missing = await request( + `${detail}/versions/999999/content`, + { method: 'HEAD', headers }, + true + ) + assert.equal(missing.status, 404) + const generated = await create( + 'project', + 'v2-head.pdf', + 'text/x-pdflibjs', + 'throw new Error("HEAD must not compile")' + ) + for (const path of [ + `/api/v2/projects/${projectId}/files/${required(generated.id)}/content`, + `/api/v2/projects/${projectId}/files/bulk-download?fileIds=${required(generated.id)}`, + ]) { + const head = await request(path, { method: 'HEAD', headers }, true) + assert.equal(head.status, 200) + assert.equal(await head.text(), '') + assert.equal(head.headers.get('cache-control'), 'private, no-store') + } + } +) +await check('Workspace immutable URLs, authorized 304 and browser cache reuse', async () => { + const file = await create('workspace', 'cache.txt', 'text/plain', 'workspace cache bytes') + const path = `/api/files/serve/${required(file.key)}` + const first = await request(path) + assert.equal(first.status, 200) + const etag = required(first.headers.get('etag')) + const conditional = await request(path, { headers: { 'If-None-Match': etag } }) + assert.equal(conditional.status, 304) + assert.equal(conditional.headers.get('x-content-type-options'), 'nosniff') + const denied = await request(path, { headers: { 'If-None-Match': etag } }, true) + assert.equal(denied.status, 401) + const immutable = await request(`${path}?v=1`) + assert.equal(immutable.headers.get('cache-control'), 'private, max-age=31536000, immutable') + const browser = await chromium.launch({ channel: 'chrome', headless: true }) + try { + const context = await browser.newContext() + await context.addCookies( + cookie.split('; ').map((entry) => ({ + name: entry.slice(0, entry.indexOf('=')), + value: entry.slice(entry.indexOf('=') + 1), + url: base.origin, + })) + ) + const page = await context.newPage() + await page.goto(new URL('/api/health', base).href) + const cdp = await context.newCDPSession(page) + await cdp.send('Network.enable') + const cached: string[] = [] + cdp.on('Network.requestServedFromCache', (event) => cached.push(event.requestId)) + for (let attempt = 0; attempt < 2; attempt++) { + const text = await page.evaluate( + async (url) => (await fetch(url)).text(), + `${path}?v=browser` + ) + assert.equal(text, 'workspace cache bytes') + } + const timing = await page.evaluate(() => + performance.getEntriesByType('resource').map((entry) => { + const resource = entry as PerformanceResourceTiming + return { + name: resource.name, + transferSize: resource.transferSize, + encodedBodySize: resource.encodedBodySize, + } + }) + ) + await writeFile(`${reportPath}.cache.json`, JSON.stringify({ cached, timing }, null, 2)) + assert.ok( + cached.length > 0 || + timing + .filter((entry) => entry.name.endsWith('?v=browser')) + .some((entry) => entry.transferSize === 0 && entry.encodedBodySize > 0), + 'Second browser fetch must use its private cache' + ) + const svg = await create( + 'workspace', + 'sandbox.svg', + 'image/svg+xml', + '' + ) + await page.goto(new URL(`/api/files/serve/${required(svg.key)}`, base).href) + assert.equal(await page.evaluate('globalThis.deliveryExecuted'), undefined) + await page.screenshot({ path: `${reportPath}.svg.png` }) + } finally { + await browser.close() + } +}) +await check('Nonfile and file JSON responses retain the application CSP', async () => { + for (const path of ['/api/health', prefix]) { + const response = await request(path) + assert.equal(response.status, 200) + assert.match(response.headers.get('content-security-policy') ?? '', /default-src/) + assert.doesNotMatch(response.headers.get('content-security-policy') ?? '', /sandbox/) + } +}) +if (checks.some((check) => !check.passed)) process.exitCode = 1 diff --git a/apps/sim/scripts/test-file-list-realtime-e2e.ts b/apps/sim/scripts/test-file-list-realtime-e2e.ts index 9dff42c7726..847478b96a6 100644 --- a/apps/sim/scripts/test-file-list-realtime-e2e.ts +++ b/apps/sim/scripts/test-file-list-realtime-e2e.ts @@ -416,6 +416,38 @@ try { }) } ) + await check('copy emits only to the canonical destination owner', async () => { + const workspaceFile = toRecord( + json( + await request(`/api/workspaces/${fixture.workspaceId}/files`, 'POST', { + name: 'copy-source.txt', + content: 'copy bytes', + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file + ) + await sleep(200) + const before = workspaceEvents.length + await changed(async () => { + json( + await request('/api/files/copy', 'POST', { + source: { + owner: { entityType: 'workspace', entityId: fixture.workspaceId }, + fileIds: [required(workspaceFile.id)], + folderIds: [], + }, + destination: { + owner: { entityType: 'project', entityId: fixture.projectId }, + folderId: null, + }, + }), + 201 + ) + }) + assert.equal(workspaceEvents.length, before) + }) await check( 'upload allocation stays silent and only completed registration invalidates', async () => { diff --git a/apps/sim/scripts/test-mcp-host-e2e.ts b/apps/sim/scripts/test-mcp-host-e2e.ts index 208ec1ae065..e2632a88f05 100644 --- a/apps/sim/scripts/test-mcp-host-e2e.ts +++ b/apps/sim/scripts/test-mcp-host-e2e.ts @@ -3,16 +3,20 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { SIM_DOCS_URL } from '@sim/utils/site' /** - * Exercises the public ownership challenge, host isolation, and the app-wide JSON 404 through a - * running local app. + * Exercises the public ownership challenge, host isolation, the RFC 9727 API catalog, and the + * app-wide JSON 404 through a running local app. */ const logger = createLogger('McpHostE2E') const CHALLENGE_PATH = '/.well-known/openai-apps-challenge' const EXPECTED_CHALLENGE = 'lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI' const MCP_HOST = 'mcp.sim.test' const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource' +const API_CATALOG_PATH = '/.well-known/api-catalog' +const API_CATALOG_CONTENT_TYPE = + 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"' const startedAt = new Date().toISOString() const configuredBaseUrl = process.env.MCP_HOST_E2E_BASE_URL @@ -83,7 +87,7 @@ try { }) } - for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health']) { + for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health', API_CATALOG_PATH]) { await check(`dedicated MCP host rejects ${path}`, async () => { const { response } = await request(path, 'Mozilla/5.0') assert.equal(response.status, 404) @@ -109,6 +113,31 @@ try { }) } + for (const userAgent of ['', 'python-requests/2.32.3']) { + await check(`API catalog for ${userAgent || 'an empty User-Agent'}`, async () => { + const { response, body } = await request(API_CATALOG_PATH, userAgent, baseUrl.host) + assert.equal(response.status, 200) + assert.equal(response.headers.get('content-type'), API_CATALOG_CONTENT_TYPE) + assert.equal( + response.headers.get('link'), + `<${baseUrl.origin}${API_CATALOG_PATH}>; rel="api-catalog"` + ) + const [api] = JSON.parse(body).linkset + assert.equal(api.anchor, `${baseUrl.origin}/api/v2`) + assert.equal(api['service-desc'][0].href, `${SIM_DOCS_URL}/openapi.json`) + assert.equal(api.status[0].href, `${baseUrl.origin}/api/health`) + const health = await request(new URL(api.status[0].href).pathname, userAgent, baseUrl.host) + assert.equal(health.response.status, 200, 'The catalog status link must resolve') + }) + } + + await check('API catalog answers HEAD with its Link header (RFC 9727 Β§2)', async () => { + const { response, body } = await request(API_CATALOG_PATH, '', baseUrl.host, 'HEAD') + assert.equal(response.status, 200) + assert.equal(body, '') + assert.match(response.headers.get('link') ?? '', /rel="api-catalog"$/) + }) + await check('application host still serves health checks', async () => { const { response } = await request('/api/health', 'Mozilla/5.0', baseUrl.host) assert.equal(response.status, 200) diff --git a/apps/sim/scripts/test-project-file-inline-source-e2e.ts b/apps/sim/scripts/test-project-file-inline-source-e2e.ts new file mode 100644 index 00000000000..8a5f1aa1329 --- /dev/null +++ b/apps/sim/scripts/test-project-file-inline-source-e2e.ts @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { toArray, toRecord } from '@sim/utils/object' +import { createPublicFileContentSource } from '@/hooks/use-file-content-source' + +const base = new URL(required(process.env.PROJECT_INLINE_SOURCE_BASE_URL)) +assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)) +const fixturePath = required(process.env.PROJECT_INLINE_SOURCE_FIXTURE_PATH) +const reportPath = required(process.env.PROJECT_INLINE_SOURCE_REPORT_PATH) +const fixture = toRecord(JSON.parse(await readFile(fixturePath, 'utf8'))) +const account = toRecord( + JSON.parse(await readFile(join(dirname(fixturePath), 'owner-account.json'), 'utf8')) +) +const cookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const doc = toRecord(toArray(fixture.files).find((value) => toRecord(value).owner === 'project')) +const foreign = toRecord(fixture.wrongOwner) +const owner = { entityType: 'project', entityId: required(fixture.projectId) } as const +const token = required(doc.token) +const contentPath = `/api/files/public/${token}/content` +const ownPath = `/api/projects/${owner.entityId}/files/${required(doc.imageId)}/content` +const foreignPath = `/api/projects/${required(foreign.projectId)}/files/${required(foreign.fileId)}/content` +const publicSource = createPublicFileContentSource(token, contentPath, owner) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] + +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required fixture or environment field missing') + return value +} +async function request(path: string, authenticated = false) { + // boundary-raw-fetch: this proof pairs the actual browser source adapter with real private and public image delivery. + return fetch(new URL(path, base), { + headers: { ...(authenticated ? { Cookie: cookie } : {}), 'X-Forwarded-For': '203.0.113.242' }, + signal: AbortSignal.timeout(90_000), + }) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } +} + +await check( + 'The foreign image exists and the signed-in viewer can read its private URL', + async () => { + const response = await request(foreignPath, true) + assert.equal(response.status, 200) + assert.equal(response.headers.get('content-type'), 'image/png') + assert.ok((await response.arrayBuffer()).byteLength > 0) + } +) +await check( + 'Private Project inline caching distinguishes immutable keys from live file IDs', + async () => { + const metadata = await request( + `/api/projects/${owner.entityId}/files/${required(doc.imageId)}`, + true + ) + assert.equal(metadata.status, 200) + const key = required(toRecord(toRecord(await metadata.json()).file).key) + for (const query of [ + `key=${encodeURIComponent(key)}`, + `fileId=${encodeURIComponent(required(doc.imageId))}`, + ]) { + const response = await request(`/api/projects/${owner.entityId}/files/inline?${query}`, true) + assert.equal(response.status, 200) + assert.equal( + response.headers.get('cache-control'), + query.startsWith('key=') + ? 'private, max-age=31536000, immutable' + : 'private, no-cache, must-revalidate' + ) + assert.ok((await response.arrayBuffer()).byteLength > 0) + } + } +) +await check( + 'The document token grants its own image and conceals the foreign Project image', + async () => { + assert.equal( + (await request(`/api/files/public/${token}/inline?fileId=${required(doc.imageId)}`)).status, + 200 + ) + assert.equal( + (await request(`/api/files/public/${token}/inline?fileId=${required(foreign.fileId)}`)) + .status, + 404 + ) + const response = await request(contentPath) + assert.equal(response.status, 200) + assert.ok( + (await response.text()).includes(foreignPath), + 'Fixture must actually embed the foreign private URL' + ) + } +) +await check( + 'Canonical and private same-owner image references stay on the public token pipeline', + async () => { + const expected = `/api/files/public/${token}/inline?fileId=${required(doc.imageId)}` + assert.equal(publicSource.resolveImageSrc(ownPath), expected) + assert.equal( + publicSource.resolveImageSrc(`sim:file/${required(doc.imageId)}?project=${owner.entityId}`), + expected + ) + } +) +await check('Public sources omit foreign-owner private image URLs', async () => { + assert.equal(publicSource.resolveImageSrc(foreignPath), undefined) +}) + +await mkdir(dirname(reportPath), { recursive: true }) +await writeFile( + reportPath, + JSON.stringify( + { + boundary: + 'Real browser source adapter plus actual HTTP; native browser screenshot remains a separate check', + checks, + }, + null, + 2 + ) +) +process.exitCode = checks.some((check) => !check.passed) ? 1 : 0 diff --git a/apps/sim/scripts/test-project-file-public-e2e.ts b/apps/sim/scripts/test-project-file-public-e2e.ts new file mode 100644 index 00000000000..12db759cc67 --- /dev/null +++ b/apps/sim/scripts/test-project-file-public-e2e.ts @@ -0,0 +1,508 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, truncate, unlink, writeFile } from 'node:fs/promises' +import { dirname, join, resolve, sep } from 'node:path' +import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId, generateShortId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import Redis from 'ioredis' +import postgres from 'postgres' +import sharp from 'sharp' +import { encryptSecret } from '@/lib/core/security/encryption' +import { getStorageProvider } from '@/lib/uploads/config' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { fileDocumentCacheIdentity, getE2BDocFormat } from '@/lib/uploads/documents/compile' +import { compiledArtifactKey, storeCompiledDoc } from '@/lib/uploads/documents/compiled-store' + +/** Real public wire/cookie proof; SQL and Redis only seed disposable sharing and OTP fixtures. */ +const base = new URL(required(process.env.PROJECT_FILE_PUBLIC_BASE_URL)) +const databaseUrl = new URL(required(process.env.DATABASE_URL)) +const redisUrl = new URL(required(process.env.REDIS_URL)) +for (const url of [base, databaseUrl, redisUrl]) + assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), 'Local runtime required') +assertDisposableTestDatabaseUrl(databaseUrl.toString()) +assert.equal(base.protocol, 'http:') +const reportPath = required(process.env.PROJECT_FILE_PUBLIC_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_PUBLIC_FIXTURE_DIR) +const keepFixtures = process.env.PROJECT_FILE_PUBLIC_KEEP_FIXTURES === '1' +const sql = postgres(databaseUrl.toString(), { max: 2 }) +const redis = new Redis(redisUrl.toString(), { maxRetriesPerRequest: 1 }) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +const files: { owner: 'workspace' | 'project'; ownerId: string; id: string }[] = [] +const shares: { id: string; token: string; fileId: string; owner: 'workspace' | 'project' }[] = [] +const otpKeys: string[] = [] +const proofIp = '203.0.113.241' +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required environment or fixture field missing') + return value +} +async function fixture(name: string) { + return toRecord(JSON.parse(await readFile(join(fixtureDir, name), 'utf8'))) +} +const account = await fixture('owner-account.json') +const sessionCookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const projectId = required(toRecord((await fixture('http-project-fixture.json')).project).id) +const workspaceId = required(process.env.PROJECT_FILE_PUBLIC_WORKSPACE_ID) +const [actor] = await sql<{ email: string }[]>` + SELECT u.email FROM project p JOIN "user" u ON u.id = p.owner_id WHERE p.id = ${projectId} +` +assert.ok(actor, 'Project owner fixture missing') +const allowedEmail = actor.email +const password = 'Local-public-file-proof-password' +const encryptedPassword = (await encryptSecret(password)).encrypted +const image = await sharp({ + create: { width: 96, height: 96, channels: 4, background: '#1976d2' }, +}) + .png() + .toBuffer() + +async function request( + path: string, + options: { method?: string; body?: Record; cookie?: string } = {} +) { + const headers = new Headers({ + Origin: base.origin, + 'Content-Type': 'application/json', + 'X-Forwarded-For': proofIp, + }) + if (options.cookie) headers.set('Cookie', options.cookie) + // boundary-raw-fetch: this proof exercises actual public binary, RSC, and credential HTTP surfaces. + const response = await fetch(new URL(path, base), { + method: options.method ?? 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: AbortSignal.timeout(90_000), + redirect: 'manual', + }) + const buffer = Buffer.from(await response.arrayBuffer()) + return { status: response.status, headers: response.headers, buffer, text: buffer.toString() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +function assertBinaryError(response: Awaited>, status: 404 | 413) { + const body = json(response, status) + assert.deepEqual(Object.keys(body).sort(), ['error', 'message']) + assert.equal(body.error, status === 404 ? 'FileNotFoundError' : 'PayloadSizeLimitError') + if (status === 404) assert.equal(body.message, 'Not found') + else assert.ok(typeof body.message === 'string' && body.message.includes('104857600')) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +async function withEmptyRateBucket(key: string, run: () => Promise) { + const redisKey = `ratelimit:tb:${key}` + const original = await redis.hgetall(redisKey) + const ttl = await redis.pttl(redisKey) + await redis.hset(redisKey, { tokens: '0', lastRefillAt: String(Date.now()) }) + await redis.expire(redisKey, 120) + try { + await run() + } finally { + await redis.del(redisKey) + if (Object.keys(original).length) { + await redis.hset(redisKey, original) + if (ttl > 0) await redis.pexpire(redisKey, ttl) + } + } +} +async function expectRateLimit(path: string, options?: Parameters[1]) { + const response = await request(path, options) + assert.equal(response.status, 429) + assert.ok(Number(response.headers.get('retry-after')) > 0, 'Retry-After missing') +} +async function withSparseFile(fileId: string, bytes: number, run: () => Promise) { + assert.equal(getStorageProvider(), 'Local', 'Sparse boundary proof requires local storage') + const [row] = await sql<{ key: string }[]>`SELECT key FROM workspace_files WHERE id=${fileId}` + assert.ok(row && files.some((file) => file.id === fileId), 'Only this run fixture may be resized') + const path = resolve(UPLOAD_DIR_SERVER, row.key) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + const original = await readFile(path) + try { + await truncate(path, bytes) + await run() + } finally { + await writeFile(path, original) + } +} +async function createFile( + owner: 'workspace' | 'project', + name: string, + content: string, + type: string, + encoding = 'utf-8' +) { + const ownerId = owner === 'project' ? projectId : workspaceId + const file = toRecord( + json( + await request(`/api/${owner === 'project' ? 'projects' : 'workspaces'}/${ownerId}/files`, { + method: 'POST', + cookie: sessionCookie, + body: { name, content, contentType: type, encoding }, + }), + 201 + ).file + ) + files.push({ owner, ownerId, id: required(file.id) }) + return file +} +async function shareFile(owner: 'workspace' | 'project', fileId: string) { + const id = generateId() + const token = generateShortId() + await sql`INSERT INTO public_share (id, resource_type, resource_id, entity_type, entity_id, workspace_id, created_by, token) + SELECT ${id}, 'file', f.id, CASE WHEN f.project_id IS NOT NULL THEN 'project' ELSE 'workspace' END, + coalesce(f.project_id, f.workspace_id), f.workspace_id, f.user_id, ${token} + FROM workspace_files f WHERE f.id = ${fileId}` + const share = { id, token, fileId, owner } + shares.push(share) + return share +} +function cookieFrom(response: Awaited>, shareId: string) { + const value = response.headers.get('set-cookie') ?? '' + assert.ok(value.startsWith(`file_auth_${shareId}=`), 'Expected resource-bound auth cookie') + for (const attribute of ['HttpOnly', 'SameSite=Lax', 'Path=/', 'Max-Age=86400']) + assert.ok( + value.toLowerCase().includes(attribute.toLowerCase()), + `Missing ${attribute} cookie attribute` + ) + return value.split(';')[0] +} +try { + const browserFixtures = [] + for (const owner of ['workspace', 'project'] as const) { + const suffix = generateShortId(8) + const photo = await createFile( + owner, + `public-photo-${suffix}.png`, + image.toString('base64'), + 'image/png', + 'base64' + ) + const reference = + owner === 'project' + ? `sim:file/${required(photo.id)}?project=${projectId}` + : `/api/files/view/${required(photo.id)}` + const privateReference = + owner === 'project' + ? `/api/projects/${projectId}/files/${required(photo.id)}/content` + : `/api/files/view/${required(photo.id)}` + const source = `# Public owner proof\n\n![Canonical image](${reference})\n\n![Private URL image](${privateReference})\n` + const doc = await createFile(owner, `public-document-${suffix}.md`, source, 'text/markdown') + const share = await shareFile(owner, required(doc.id)) + browserFixtures.push({ + owner, + fileId: doc.id, + imageId: photo.id, + token: share.token, + source, + privateUrl: `/workspace/${workspaceId}/files/${required(doc.id)}${owner === 'project' ? `?owner=project&projectId=${projectId}` : ''}`, + publicUrl: `/f/${share.token}`, + }) + const path = `/api/files/public/${share.token}` + await check(`${owner}: public metadata and source preserve safe wire and headers`, async () => { + const metadata = json(await request(path)) + assert.equal(metadata.name, doc.name) + assert.deepEqual(Object.keys(metadata).sort(), [ + 'name', + 'ownerName', + 'size', + 'token', + 'type', + 'workspaceName', + ]) + assert.ok( + typeof metadata.workspaceName === 'string' && metadata.workspaceName, + 'Canonical owner display name missing' + ) + const bytes = await request(`${path}/content`) + assert.equal(bytes.status, 200) + assert.equal(bytes.text, source) + assert.equal(bytes.headers.get('cache-control'), 'private, no-cache, must-revalidate') + assert.equal(bytes.headers.get('x-content-type-options'), 'nosniff') + assert.match(bytes.headers.get('content-disposition') ?? '', /filename=/) + }) + await check( + `${owner}: HEAD rejects a missing stored source without generating content`, + async () => { + assert.equal(getStorageProvider(), 'Local', 'Missing-object fixture requires local storage') + const file = await createFile( + owner, + `head-missing-${suffix}.txt`, + 'head source', + 'text/plain' + ) + const share = await shareFile(owner, required(file.id)) + const contentPath = `/api/files/public/${share.token}/content` + assert.equal((await request(contentPath, { method: 'HEAD' })).status, 200) + const [stored] = await sql< + { key: string }[] + >`SELECT key FROM workspace_files WHERE id = ${required(file.id)}` + const path = resolve(UPLOAD_DIR_SERVER, required(stored?.key)) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + const bytes = await readFile(path) + try { + await unlink(path) + const missing = await request(contentPath, { method: 'HEAD' }) + assert.equal(missing.status, 404) + assert.equal(missing.buffer.length, 0) + } finally { + await writeFile(path, bytes) + } + } + ) + await check(`${owner}: inline image requires the current document grant`, async () => { + const bytes = await request(`${path}/inline?fileId=${required(photo.id)}`) + assert.equal(bytes.status, 200) + assert.ok(bytes.buffer.equals(image), 'Inline bytes differ from fixture') + assert.equal(bytes.headers.get('content-type'), 'image/png') + const head = await request(`${path}/inline?fileId=${required(photo.id)}`, { method: 'HEAD' }) + assert.equal(head.status, 200) + assert.equal(head.buffer.length, 0) + assertBinaryError(await request(`${path}/inline?fileId=unreferenced`), 404) + assert.equal( + (await request(`${path}/inline?fileId=${required(photo.id)}&key=other`)).status, + 400 + ) + }) + await check( + `${owner}: source and inline scan enforce independent real byte ceilings`, + async () => { + await withSparseFile(required(doc.id), 100 * 1024 * 1024 + 1, async () => { + assertBinaryError(await request(`${path}/content`), 413) + assertBinaryError(await request(`${path}/inline?fileId=${required(photo.id)}`), 404) + }) + await withSparseFile(required(photo.id), 100 * 1024 * 1024 + 1, async () => { + assertBinaryError(await request(`${path}/inline?fileId=${required(photo.id)}`), 413) + }) + } + ) + await check( + `${owner}: a small generated source cannot bypass the compiled output ceiling`, + async () => { + assert.equal(getStorageProvider(), 'Local', 'Artifact fixture requires local storage') + const source = `// Public output ceiling fixture ${suffix}` + const generated = await createFile( + owner, + `public-output-${suffix}.pptx`, + source, + 'text/x-pptxgenjs' + ) + const artifactShare = await shareFile(owner, required(generated.id)) + const contentPath = `/api/files/public/${artifactShare.token}/content` + const unavailable = await request(contentPath) + assert.equal( + json(unavailable, 409).error, + 'This document is still being prepared. Please try again shortly.' + ) + const format = await getE2BDocFormat('output.pptx') + assert.ok(format) + const cacheIdentity = + owner === 'project' + ? fileDocumentCacheIdentity(source, format, 'text/x-pptxgenjs') + : undefined + const key = compiledArtifactKey( + { entityType: owner, entityId: owner === 'workspace' ? workspaceId : projectId }, + source, + 'pptx', + cacheIdentity + ) + await storeCompiledDoc( + { entityType: owner, entityId: owner === 'workspace' ? workspaceId : projectId }, + source, + 'pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + Buffer.from('PK\u0003\u0004output-fixture'), + cacheIdentity + ) + const path = resolve(UPLOAD_DIR_SERVER, required(key)) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + try { + await truncate(path, 100 * 1024 * 1024 + 1) + assertBinaryError(await request(contentPath), 413) + } finally { + await unlink(path) + } + } + ) + await check(`${owner}: public read budgets precede target lookup`, async () => { + const unknown = `/api/files/public/${generateShortId()}` + for (const [scope, target] of [ + ['metadata', unknown], + ['content', `${unknown}/content`], + ['inline', `${unknown}/inline?fileId=unknown`], + ]) + await withEmptyRateBucket(`public-file:${scope}:${proofIp}`, () => expectRateLimit(target)) + }) + await check(`${owner}: public mode cannot mint a password cookie`, async () => { + const response = await request(path, { method: 'POST', body: { password } }) + json(response, 400) + assert.equal(response.headers.get('set-cookie'), null) + }) + await sql`UPDATE public_share SET auth_type='password', password=${encryptedPassword} WHERE id=${share.id}` + await check( + `${owner}: protected RSC hides filename while password cookie unlocks bytes`, + async () => { + const page = await request(`/f/${share.token}`) + assert.equal(page.status, 200) + assert.equal( + page.text.includes(required(doc.name)), + false, + 'Protected page disclosed filename' + ) + assert.equal(json(await request(path), 401).error, 'auth_required_password') + assert.equal( + json(await request(path, { method: 'POST', body: { password: 'wrong' } }), 401).error, + 'Invalid password' + ) + for (const rateKey of [ + `file-password:ip:${share.id}:${proofIp}`, + `file-password:resource:${share.id}`, + ]) + await withEmptyRateBucket(rateKey, () => + expectRateLimit(path, { method: 'POST', body: { password } }) + ) + const accepted = await request(path, { method: 'POST', body: { password } }) + assert.equal(json(accepted).authType, 'password') + const cookie = cookieFrom(accepted, share.id) + assert.equal((await request(`${path}/content`, { cookie })).text, source) + await sql`UPDATE public_share SET password=${(await encryptSecret(`${password}-changed`)).encrypted} WHERE id=${share.id}` + assert.equal((await request(`${path}/content`, { cookie })).status, 401) + } + ) + await sql`UPDATE public_share SET auth_type='email', password=NULL, allowed_emails=${sql.json([allowedEmail])} WHERE id=${share.id}` + await check( + `${owner}: email OTP denial, consumption, and cookie policy stay current`, + async () => { + const rejectedEmail = 'public-proof-rejected@example.invalid' + await withEmptyRateBucket(`file-otp:ip:${proofIp}`, () => + expectRateLimit(`${path}/otp`, { method: 'POST', body: { email: rejectedEmail } }) + ) + assert.equal( + json(await request(`${path}/otp`, { method: 'POST', body: { email: rejectedEmail } })) + .message, + 'Verification code sent' + ) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: rejectedEmail, otp: '123456' }, + }) + ).status, + 400 + ) + const key = `otp:file:${allowedEmail}:${share.id}` + otpKeys.push(key) + await redis.set(key, '123456:0', 'EX', 900) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '000000' }, + }) + ).status, + 400 + ) + const accepted = await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '123456' }, + }) + assert.equal(json(accepted).authType, 'email') + const cookie = cookieFrom(accepted, share.id) + assert.equal(await redis.get(key), null, 'Successful verification did not consume OTP') + await redis.set(key, '123456:4', 'EX', 900) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '000000' }, + }) + ).status, + 400 + ) + assert.equal(await redis.get(key), null, 'Attempt exhaustion did not consume OTP') + assert.equal((await request(path, { cookie })).status, 200) + await sql`UPDATE public_share SET allowed_emails='[]'::json WHERE id=${share.id}` + assert.equal((await request(path, { cookie })).status, 401) + } + ) + await sql`UPDATE public_share SET auth_type='sso', allowed_emails=${sql.json([allowedEmail])} WHERE id=${share.id}` + await check(`${owner}: SSO eligibility alone never grants file access`, async () => { + for (const rateKey of [`file-sso:ip:${proofIp}`, `file-sso:resource:${share.id}`]) + await withEmptyRateBucket(rateKey, () => + expectRateLimit(`${path}/sso`, { method: 'POST', body: { email: allowedEmail } }) + ) + assert.equal( + json(await request(`${path}/sso`, { method: 'POST', body: { email: allowedEmail } })) + .eligible, + true + ) + assert.equal( + json( + await request(`${path}/sso`, { + method: 'POST', + body: { email: 'rejected@example.invalid' }, + }) + ).eligible, + false + ) + assert.equal(json(await request(path), 401).error, 'auth_required_sso') + assert.equal((await request(path, { cookie: sessionCookie })).status, 200) + }) + await sql`UPDATE public_share SET auth_type='public', password=NULL, allowed_emails='[]'::json WHERE id=${share.id}` + } + await writeFile( + join(fixtureDir, 'public-browser-fixtures.json'), + JSON.stringify({ projectId, workspaceId, files: browserFixtures }, null, 2), + { mode: 0o600 } + ) +} catch (error) { + checks.push({ + name: 'fixture setup', + passed: false, + durationMs: 0, + error: getErrorMessage(error), + }) +} finally { + if (!keepFixtures) { + for (const share of shares) + await sql`UPDATE public_share SET is_active=false WHERE id=${share.id}` + for (const owner of ['workspace', 'project'] as const) { + const owned = files.filter((file) => file.owner === owner) + const first = owned[0] + if (!first) continue + await check(`${owner}: fixture cleanup archives only this run's files`, async () => { + const prefix = `/api/${owner === 'project' ? 'projects' : 'workspaces'}/${first.ownerId}/files` + json( + await request(`${prefix}/${owner === 'project' ? 'archive' : 'bulk-archive'}`, { + method: 'POST', + cookie: sessionCookie, + body: { fileIds: owned.map((file) => file.id) }, + }) + ) + }) + } + } + for (const key of otpKeys) await redis.del(key) + await redis.quit() + await sql.end() + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +process.exitCode = checks.some((entry) => !entry.passed) ? 1 : 0 diff --git a/apps/sim/scripts/test-project-file-rendered-e2e.ts b/apps/sim/scripts/test-project-file-rendered-e2e.ts new file mode 100644 index 00000000000..852d23860e3 --- /dev/null +++ b/apps/sim/scripts/test-project-file-rendered-e2e.ts @@ -0,0 +1,430 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import JSZip from 'jszip' +import { PDFDocument, PDFName } from 'pdf-lib' +import sharp from 'sharp' + +type Auth = 'session' | 'personal' | 'workspace' | 'none' +type Format = 'docx' | 'pptx' | 'pdf' +interface CreatedFile { + id: string + projectId: string + name: string + revision: string + source?: string +} +interface Check { + name: string + passed: boolean + durationMs: number + error?: string +} + +const reportPath = required( + process.env.PROJECT_FILE_RENDERED_REPORT_PATH, + 'PROJECT_FILE_RENDERED_REPORT_PATH is required' +) +const fixtureDir = required(process.env.PROJECT_FILE_RENDERED_FIXTURE_DIR) +const runId = generateId() +const artifactDir = join(dirname(reportPath), `${basename(reportPath, '.json')}-${runId}`) +const checks: Check[] = [] +const requests: { method: string; path: string; auth: Auth; status: number; durationMs: number }[] = + [] +const artifacts: { format: Format; path: string; size: number; sha256: string }[] = [] +const files: CreatedFile[] = [] +const archived = new Set() +const documents = new Map() +const rendered = new Map() +const marker = `ProjectRendered${runId.replaceAll('-', '')}` +const mimeTypes = { + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + pdf: 'application/pdf', +} as const +const sourceMimeTypes = { + docx: 'text/x-docxjs', + pptx: 'text/x-pptxgenjs', + pdf: 'text/x-pdflibjs', +} as const +let base: URL +let cookie = '' +let keys: Record = {} +let projectId = '' +let foreignProjectId = '' +let asset: CreatedFile | undefined +let foreignAsset: CreatedFile | undefined +let firstImage = Buffer.alloc(0) +let secondImage = Buffer.alloc(0) + +function required(value: unknown, message = 'Required fixture field missing'): string { + assert.ok(typeof value === 'string' && value.length > 0, message) + return value +} + +async function fixture(name: string) { + return toRecord(JSON.parse(await readFile(join(fixtureDir, name), 'utf8'))) +} + +async function saveReport() { + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify( + { + runId, + boundary: 'Running HTTP application, real isolated-vm document tasks and Project assets', + limitation: 'Remote Python/XLSX rendering is not exercised without configured credentials', + checks, + requests, + artifacts, + }, + null, + 2 + ) + ) +} + +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + return true + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + return false + } finally { + await saveReport() + } +} + +async function request( + method: string, + path: string, + auth: Auth = 'personal', + body?: Record +) { + const headers = new Headers({ Origin: base.origin, 'Content-Type': 'application/json' }) + if (auth === 'session') headers.set('Cookie', cookie) + if (auth === 'personal' || auth === 'workspace') headers.set('X-API-Key', required(keys[auth])) + const start = performance.now() + // boundary-raw-fetch: this standalone proof validates real document bytes through the disposable local HTTP app. + const response = await fetch(new URL(path, base), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(120_000), + }) + const buffer = Buffer.from(await response.arrayBuffer()) + requests.push({ + method, + path, + auth, + status: response.status, + durationMs: performance.now() - start, + }) + return { status: response.status, headers: response.headers, buffer } +} + +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status, `Expected HTTP ${status}, received ${response.status}`) + assert.match(response.headers.get('content-type') ?? '', /application\/json/) + return toRecord(JSON.parse(response.buffer.toString('utf8'))) +} + +function filePath(file: CreatedFile) { + return `/api/v2/projects/${file.projectId}/files/${file.id}` +} + +function artifactPath(file: CreatedFile) { + return `/api/projects/${file.projectId}/files/${file.id}/artifact` +} + +function archivePath(file: CreatedFile) { + return `/api/v2/projects/${file.projectId}/files/bulk-download?fileIds=${encodeURIComponent(file.id)}` +} + +async function create( + ownerId: string, + name: string, + content: string | Buffer, + contentType: string +) { + const data = toRecord( + json( + await request('POST', `/api/v2/projects/${ownerId}/files`, 'personal', { + name, + content: typeof content === 'string' ? content : content.toString('base64'), + encoding: typeof content === 'string' ? 'utf-8' : 'base64', + contentType, + }), + 201 + ).data + ) + const file = { + id: required(data.id), + projectId: ownerId, + name, + revision: required(data.revision), + ...(typeof content === 'string' ? { source: content } : {}), + } + files.push(file) + return file +} + +function program(format: Format, imageId: string) { + if (format === 'docx') + return `addSection({ children: [new docx.Paragraph(${JSON.stringify(marker)}), new docx.Paragraph({ children: [await addImage(${JSON.stringify(imageId)}, {width: 32, height: 32})] })] });` + if (format === 'pptx') + return `const slide = pptx.addSlide(); slide.addText(${JSON.stringify(marker)}, {x: 0.5, y: 0.5, w: 8, h: 0.5}); await addImage(slide, ${JSON.stringify(imageId)}, {x: 1, y: 1, w: 1, h: 1});` + return `const page = pdf.addPage([320, 240]); const font = await pdf.embedFont(StandardFonts.Helvetica); page.drawText(${JSON.stringify(marker)}, {x: 10, y: 210, size: 8, font}); await drawImage(page, ${JSON.stringify(imageId)}, {x: 20, y: 30, width: 64, height: 64});` +} + +async function assertDocument(format: Format, buffer: Buffer, image: Buffer) { + if (format === 'pdf') { + assert.equal(buffer.subarray(0, 5).toString(), '%PDF-') + const pdf = await PDFDocument.load(buffer) + assert.equal(pdf.getPageCount(), 1) + const page = pdf.getPage(0) + assert.deepEqual(page.getSize(), { width: 320, height: 240 }) + assert.ok( + page.node.Resources()?.has(PDFName.of('XObject')), + 'PDF must contain the prepared image' + ) + return + } + const zip = await JSZip.loadAsync(buffer) + const xml = zip.file(format === 'docx' ? 'word/document.xml' : 'ppt/slides/slide1.xml') + assert.ok(xml, 'Rendered Office archive must contain its document XML') + assert.ok( + (await xml.async('string')).includes(marker), + 'Rendered document must contain the source marker' + ) + const media = zip.file(format === 'docx' ? /^word\/media\// : /^ppt\/media\//) + const bytes = await Promise.all(media.map((entry) => entry.async('nodebuffer'))) + assert.ok( + bytes.some((value) => value.equals(image)), + 'Rendered document must embed the current Project image bytes' + ) +} + +async function readArtifact(file: CreatedFile, format: Format) { + const response = await request('GET', artifactPath(file), 'session') + assert.equal(response.status, 200, `Real ${format} compilation returned HTTP ${response.status}`) + assert.equal(response.headers.get('content-type')?.split(';')[0], mimeTypes[format]) + return response.buffer +} + +try { + const ready = await check( + 'Local fixtures create source documents and images through real APIs', + async () => { + base = new URL(process.env.PROJECT_FILE_RENDERED_BASE_URL ?? 'http://127.0.0.1:3300') + assert.ok( + ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname), + 'Requires a disposable loopback runtime' + ) + keys = await fixture('v2-fixture-keys.json') + cookie = toArray((await fixture('owner-account.json')).cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') + assert.ok(cookie) + projectId = required(toRecord((await fixture('http-project-fixture.json')).project).id) + foreignProjectId = + process.env.PROJECT_FILE_RENDERED_FOREIGN_PROJECT_ID ?? + required( + toRecord((await fixture('public-browser-wrong-owner-fixtures.json')).wrongOwner).projectId + ) + assert.notEqual(projectId, foreignProjectId) + firstImage = await sharp({ + create: { width: 16, height: 16, channels: 3, background: '#0055ff' }, + }) + .png() + .toBuffer() + secondImage = await sharp({ + create: { width: 16, height: 16, channels: 3, background: '#ff2200' }, + }) + .png() + .toBuffer() + asset = await create(projectId, `rendered-${runId}.png`, firstImage, 'image/png') + foreignAsset = await create( + foreignProjectId, + `rendered-foreign-${runId}.png`, + secondImage, + 'image/png' + ) + for (const format of ['docx', 'pptx', 'pdf'] as const) { + documents.set( + format, + await create( + projectId, + `rendered-${runId}.${format}`, + program(format, asset.id), + sourceMimeTypes[format] + ) + ) + } + } + ) + if (ready) { + await check('Artifact HEAD authorizes without executing a document program', async () => { + const file = await create( + projectId, + `head-only-${runId}.pdf`, + 'throw new Error("Artifact HEAD must not execute this program");', + sourceMimeTypes.pdf + ) + const head = await request('HEAD', artifactPath(file), 'session') + assert.equal(head.status, 200) + assert.equal(head.buffer.length, 0) + assert.equal((await request('HEAD', artifactPath(file), 'none')).status, 401) + const rendered = await request('GET', artifactPath(file), 'session') + assert.ok(rendered.status >= 400, 'The program must fail if actually rendered') + }) + for (const format of ['docx', 'pptx', 'pdf'] as const) { + await check( + `Real ${format.toUpperCase()} rendering returns a parseable document with a Project asset`, + async () => { + const file = documents.get(format) + assert.ok(file) + const buffer = await readArtifact(file, format) + await assertDocument(format, buffer, firstImage) + rendered.set(format, buffer) + const path = join(artifactDir, `rendered.${format}`) + await mkdir(artifactDir, { recursive: true }) + await writeFile(path, buffer) + artifacts.push({ + format, + path, + size: buffer.length, + sha256: createHash('sha256').update(buffer).digest('hex'), + }) + } + ) + } + await check('Personal-key bulk download returns the same compiled Office bytes', async () => { + const file = documents.get('docx') + const previous = rendered.get('docx') + assert.ok(file && previous, 'A successful initial render is required') + const response = await request('GET', archivePath(file)) + assert.equal(response.status, 200) + assert.equal(response.headers.get('content-type')?.split(';')[0], 'application/zip') + const archive = await JSZip.loadAsync(response.buffer) + const entry = archive.file(file.name) + assert.ok(entry) + assert.deepEqual(await entry.async('nodebuffer'), previous) + }) + await check( + 'Same-ID asset content revision invalidates the compiled document cache', + async () => { + assert.ok(asset) + const file = documents.get('docx') + const original = rendered.get('docx') + assert.ok(file && original, 'A successful initial render is required') + assert.deepEqual(await readArtifact(file, 'docx'), original) + const updated = toRecord( + json( + await request('PUT', `${filePath(asset)}/content`, 'personal', { + content: secondImage.toString('base64'), + encoding: 'base64', + expectedRevision: asset.revision, + }) + ).data + ) + assert.equal(updated.id, asset.id) + assert.notEqual(required(updated.revision), asset.revision) + const buffer = await readArtifact(file, 'docx') + await assertDocument('docx', buffer, secondImage) + assert.equal(buffer.equals(original), false) + await writeFile(join(artifactDir, 'rendered-updated.docx'), buffer) + } + ) + await check('A caller with both Projects cannot compile a foreign-owner asset', async () => { + assert.ok(foreignAsset) + const ownRead = await request('GET', `${filePath(foreignAsset)}/content`) + assert.equal(ownRead.status, 200) + assert.deepEqual(ownRead.buffer, secondImage) + const file = await create( + projectId, + `rendered-foreign-${runId}.docx`, + program('docx', foreignAsset.id), + sourceMimeTypes.docx + ) + assert.equal((await request('GET', artifactPath(file), 'session')).status, 404) + assert.equal((await request('GET', archivePath(file))).status, 404) + }) + await check( + 'Cached private document bytes still reject anonymous and workspace-key callers', + async () => { + const file = documents.get('docx') + assert.ok(file) + await readArtifact(file, 'docx') + assert.equal((await request('GET', artifactPath(file), 'none')).status, 401) + assert.equal((await request('GET', artifactPath(file), 'workspace')).status, 401) + assert.equal((await request('GET', archivePath(file), 'none')).status, 401) + assert.equal((await request('GET', archivePath(file), 'workspace')).status, 403) + } + ) + await check( + 'An archived dependency cannot be delivered from a warm artifact cache', + async () => { + assert.ok(asset) + const file = documents.get('docx') + assert.ok(file) + await readArtifact(file, 'docx') + json( + await request('POST', `/api/v2/projects/${projectId}/files/archive`, 'personal', { + fileIds: [asset.id], + }) + ) + archived.add(asset.id) + assert.equal((await request('GET', artifactPath(file), 'session')).status, 404) + assert.equal((await request('GET', archivePath(file))).status, 404) + } + ) + await check( + 'Rendering and denied re-reads preserve every editable source and revision', + async () => { + for (const file of files.filter((file) => file.source !== undefined)) { + const response = await request('GET', `${filePath(file)}/content`) + assert.equal(response.status, 200) + assert.equal(response.buffer.toString('utf8'), file.source) + const metadata = toRecord(json(await request('GET', `${filePath(file)}/metadata`)).data) + assert.equal(metadata.revision, file.revision) + } + } + ) + } +} catch (error) { + checks.push({ + name: 'Harness completion', + passed: false, + durationMs: 0, + error: getErrorMessage(error), + }) +} finally { + await check('Archive only fixtures created by this run', async () => { + const owners = new Set(files.map((file) => file.projectId)) + for (const owner of owners) { + const fileIds = files + .filter((file) => file.projectId === owner && !archived.has(file.id)) + .map((file) => file.id) + if (fileIds.length) + json( + await request('POST', `/api/v2/projects/${owner}/files/archive`, 'personal', { fileIds }) + ) + } + }) + await saveReport() + process.exitCode = checks.some((entry) => !entry.passed) ? 1 : 0 +} diff --git a/apps/sim/scripts/test-project-file-sharing-e2e.ts b/apps/sim/scripts/test-project-file-sharing-e2e.ts new file mode 100644 index 00000000000..55327a01cb7 --- /dev/null +++ b/apps/sim/scripts/test-project-file-sharing-e2e.ts @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + subscription, + user, + workspace, +} from '@sim/db/schema' +import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' +import { insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import { and, eq, inArray } from 'drizzle-orm' + +/** Actual internal sharing policy and public token proof using disposable local fixture owners. */ +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required fixture or environment value missing') + return value +} +const base = new URL(required(process.env.PROJECT_FILE_SHARING_BASE_URL)) +const database = new URL(required(process.env.DATABASE_URL)) +for (const url of [base, database]) + assert.ok(['localhost', '127.0.0.1', '[::1]'].includes(url.hostname), 'Local runtime required') +assertDisposableTestDatabaseUrl(database.toString()) +const reportPath = required(process.env.PROJECT_FILE_SHARING_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_SHARING_FIXTURE_DIR) +const account = toRecord(JSON.parse(await readFile(join(fixtureDir, 'owner-account.json'), 'utf8'))) +const cookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const parent = toRecord( + JSON.parse(await readFile(join(fixtureDir, 'http-project-fixture.json'), 'utf8')) +) +const [actor] = await db + .select({ id: project.ownerId }) + .from(project) + .where(eq(project.id, required(toRecord(parent.project).id))) +assert.ok(actor) +const [existingMembership] = await db + .select({ organizationId: member.organizationId, name: organization.name }) + .from(member) + .innerJoin(organization, eq(organization.id, member.organizationId)) + .where(eq(member.userId, actor.id)) +if (existingMembership) + assert.equal( + existingMembership.name, + 'Sharing policy proof', + 'Use an unassociated disposable account' + ) +const [existingOwner] = existingMembership + ? await db + .select({ id: member.userId }) + .from(member) + .where( + and(eq(member.organizationId, existingMembership.organizationId), eq(member.role, 'owner')) + ) + : [] +const ownerId = existingOwner?.id ?? generateId() +const organizationId = existingMembership?.organizationId ?? generateId() +const workspaceId = generateId() +const otherWorkspaceId = generateId() +const groupId = generateId() +if (!existingMembership) { + await db.insert(user).values({ + id: ownerId, + name: 'Sharing proof owner', + email: `${ownerId}@sharing.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(organization).values({ + id: organizationId, + name: 'Sharing policy proof', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values( + [ownerId, actor.id].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) +} +await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Share policy environment', + ownerId, + organizationId, + billedAccountUserId: ownerId, + workspaceMode: 'organization', +}) +await insertWorkspaceFixture(db, { + id: otherWorkspaceId, + name: 'Restricted sharing environment', + ownerId, + organizationId, + billedAccountUserId: ownerId, + workspaceMode: 'organization', + forkedFromWorkspaceId: workspaceId, +}) +const [binding] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)) +assert.ok(binding) +const projectId = binding.projectId +await db.insert(permissions).values( + [workspaceId, otherWorkspaceId].map((entityId) => ({ + id: generateId(), + userId: actor.id, + entityType: 'workspace', + entityId, + permissionType: 'admin' as const, + })) +) +await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: organizationId, + status: 'active', + metadata: {}, +}) +await db.insert(permissionGroup).values({ + id: groupId, + organizationId, + createdBy: ownerId, + name: `Sharing policy proof ${groupId}`, + membershipMode: 'inherit', + config: {}, +}) +await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: otherWorkspaceId, + organizationId, +}) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +async function request(path: string, method = 'GET', body?: object, authenticated = true) { + // boundary-raw-fetch: E2E exercises the running app's internal JSON and anonymous binary surfaces. + const response = await fetch(new URL(path, base), { + method, + headers: { + Origin: base.origin, + 'Content-Type': 'application/json', + ...(authenticated ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(90_000), + redirect: 'manual', + }) + return { status: response.status, text: await response.text() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +async function check(name: string, run: () => Promise) { + const started = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify( + { checks, fixture: { projectId, workspaceId, otherWorkspaceId, organizationId } }, + null, + 2 + ) + ) +} +const content = 'Shared Project architecture fixture' +const projectFile = toRecord( + json( + await request(`/api/projects/${projectId}/files`, 'POST', { + name: 'sharing-project.txt', + content, + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file +) +const workspaceFile = toRecord( + json( + await request(`/api/workspaces/${workspaceId}/files`, 'POST', { + name: 'sharing-environment.txt', + content: 'Environment only fixture', + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file +) +const fileId = required(projectFile.id) +const workspaceFileId = required(workspaceFile.id) +const path = `/api/projects/${projectId}/files/${fileId}/share` +let token = '' +await check('Project sharing read projects current effective policy', async () => { + const result = json(await request(path)) + assert.equal(result.share, null) + assert.deepEqual(result.policy, { + canPublish: true, + allowedAuthTypes: ['public', 'password', 'email', 'sso'], + }) + assert.deepEqual(result.capabilities, { canRead: true, canWrite: true }) +}) +await check('Read-only Project access cannot publish or revoke', async () => { + const ownPermissions = and( + eq(permissions.userId, actor.id), + inArray(permissions.entityId, [workspaceId, otherWorkspaceId]) + ) + await db.update(permissions).set({ permissionType: 'read' }).where(ownPermissions) + try { + assert.equal(toRecord(json(await request(path)).capabilities).canWrite, false) + assert.equal((await request(path, 'PUT', { isActive: true })).status, 403) + assert.equal((await request(path, 'PUT', { isActive: false })).status, 403) + } finally { + await db.update(permissions).set({ permissionType: 'admin' }).where(ownPermissions) + } +}) +await check('Wrong file owner is concealed and unauthenticated calls are refused', async () => { + assert.equal( + (await request(`/api/projects/${projectId}/files/${workspaceFileId}/share`)).status, + 404 + ) + assert.equal((await request(path, 'GET', undefined, false)).status, 401) +}) +await check('Project publication serves actual bytes with stable token', async () => { + const share = toRecord( + json(await request(path, 'PUT', { isActive: true, authType: 'public' })).share + ) + token = required(share.token) + assert.equal(share.resourceId, fileId) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).text, + content + ) + assert.equal(toRecord(json(await request(path)).share).token, token) +}) +await check( + 'Another environment policy controls offered modes and actual publication', + async () => { + await db + .update(permissionGroup) + .set({ config: { allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + assert.deepEqual(json(await request(path)).policy, { + canPublish: true, + allowedAuthTypes: ['password'], + }) + assert.equal((await request(path, 'PUT', { isActive: true, authType: 'public' })).status, 403) + const share = toRecord( + json( + await request(path, 'PUT', { + isActive: true, + authType: 'password', + password: 'Local-sharing-proof-password', + }) + ).share + ) + assert.equal(share.authType, 'password') + assert.equal(share.token, token) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).status, + 401 + ) + } +) +await check('Disabled publication preserves the right to revoke current sharing', async () => { + await db + .update(permissionGroup) + .set({ config: { disablePublicFileSharing: true, allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + assert.deepEqual(json(await request(path)).policy, { + canPublish: false, + allowedAuthTypes: ['password'], + }) + assert.equal((await request(path, 'PUT', { isActive: true, authType: 'password' })).status, 403) + const share = toRecord(json(await request(path, 'PUT', { isActive: false })).share) + assert.equal(share.isActive, false) + assert.equal(share.token, token) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).status, + 404 + ) +}) +await check('Environment sharing keeps its separate legacy policy and identity', async () => { + const workspacePath = `/api/workspaces/${workspaceId}/files/${workspaceFileId}/share` + const shared = json(await request(workspacePath, 'PUT', { isActive: true, authType: 'public' })) + assert.deepEqual(Object.keys(shared), ['share']) + const share = toRecord(shared.share) + assert.equal(share.resourceId, workspaceFileId) + assert.notEqual(share.token, token) + assert.equal(toRecord(json(await request(path)).share).isActive, false) + assert.equal( + (await request(`/api/files/public/${required(share.token)}/content`, 'GET', undefined, false)) + .text, + 'Environment only fixture' + ) + json(await request(workspacePath, 'PUT', { isActive: false })) +}) +await request(`/api/workspaces/${workspaceId}/files/${workspaceFileId}/share`, 'PUT', { + isActive: false, +}) +await request(path, 'PUT', { isActive: false }) +await writeFile( + join(fixtureDir, 'sharing-browser-fixtures.json'), + JSON.stringify( + { + projectId, + workspaceId, + otherWorkspaceId, + organizationId, + groupId, + actorId: actor.id, + fileId, + workspaceFileId, + token, + }, + null, + 2 + ), + { mode: 0o600 } +) +process.stdout.write( + `${JSON.stringify({ + reportPath, + passed: checks.filter((c) => c.passed).length, + failed: checks.filter((c) => !c.passed).length, + })}\n` +) +process.exit(checks.every((c) => c.passed) ? 0 : 1) diff --git a/apps/sim/scripts/test-project-files-e2e.ts b/apps/sim/scripts/test-project-files-e2e.ts index 984fa495cf1..20562b595b2 100644 --- a/apps/sim/scripts/test-project-files-e2e.ts +++ b/apps/sim/scripts/test-project-files-e2e.ts @@ -291,6 +291,66 @@ async function seed(name: string) { return { directory, projectId, workspaceId, foreignProjectId, cookie } } +async function seedInline(fixture: Awaited>) { + const image = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jF9sAAAAASUVORK5CYII=' + const create = async ( + projectId: string, + name: string, + content: string, + contentType: string, + encoding: string + ) => + toRecord( + ( + await request(fixture.cookie, `/api/projects/${projectId}/files`, { + name, + content, + contentType, + encoding, + }) + ).file + ) + const ownImage = await create(fixture.projectId, 'own.png', image, 'image/png', 'base64') + const foreignImage = await create( + fixture.foreignProjectId, + 'foreign.png', + image, + 'image/png', + 'base64' + ) + const foreignPath = `/api/projects/${fixture.foreignProjectId}/files/${foreignImage.id}/content` + const doc = await create( + fixture.projectId, + 'architecture.md', + `![own](sim:file/${ownImage.id}?project=${fixture.projectId})\n![foreign](${foreignPath})`, + 'text/markdown', + 'utf-8' + ) + const share = toRecord( + ( + await request( + fixture.cookie, + `/api/projects/${fixture.projectId}/files/${doc.id}/share`, + { isActive: true, authType: 'public' }, + 'PUT' + ) + ).share + ) + secrets.add(requiredString(share.token)) + const path = join(fixture.directory, 'inline-source-fixture.json') + await writeFile( + path, + JSON.stringify({ + projectId: fixture.projectId, + files: [{ owner: 'project', fileId: doc.id, imageId: ownImage.id, token: share.token }], + wrongOwner: { projectId: fixture.foreignProjectId, fileId: foreignImage.id }, + }), + { mode: 0o600 } + ) + return path +} + function interrupt() { interrupted = true void Promise.all(processes.map(stop)).catch(() => undefined) @@ -299,8 +359,14 @@ process.once('SIGINT', interrupt) process.once('SIGTERM', interrupt) const suites = [ + ['delivery', 'FILE_DELIVERY', 'test-file-delivery-e2e.ts'], ['browser', 'PROJECT_FILE_BROWSER', 'test-project-file-browser-e2e.ts'], ['history', 'PROJECT_FILE_HISTORY', 'test-project-file-history-e2e.ts'], + ['rendered', 'PROJECT_FILE_RENDERED', 'test-project-file-rendered-e2e.ts'], + ['public', 'PROJECT_FILE_PUBLIC', 'test-project-file-public-e2e.ts'], + ['sharing', 'PROJECT_FILE_SHARING', 'test-project-file-sharing-e2e.ts'], + ['inline-source', 'PROJECT_INLINE_SOURCE', 'test-project-file-inline-source-e2e.ts'], + ['copy', 'FILE_COPY', 'test-file-copy-e2e.ts'], ['realtime', 'FILE_LIST_REALTIME', 'test-file-list-realtime-e2e.ts'], ] as const @@ -354,6 +420,7 @@ try { assert.equal(interrupted, false, 'Acceptance run interrupted') await check(`Existing ${name} acceptance suite`, async () => { const fixture = await seed(name) + const fixturePath = name === 'inline-source' ? await seedInline(fixture) : undefined await run( `project-files-${name}`, ['bun', '--no-env-file', join('scripts', script)], @@ -365,6 +432,7 @@ try { [`${prefix}_REPORT_PATH`]: join(reportDir, `project-files-${name}.json`), PROJECT_FILE_RENDERED_FOREIGN_PROJECT_ID: fixture.foreignProjectId, PROJECT_FILE_PUBLIC_WORKSPACE_ID: fixture.workspaceId, + PROJECT_INLINE_SOURCE_FIXTURE_PATH: fixturePath, FILE_COPY_WORKSPACE_ID: fixture.workspaceId, FILE_LIST_REALTIME_RELAY_URL: relay.origin, } diff --git a/docker/pii-entrypoint.sh b/docker/pii-entrypoint.sh new file mode 100755 index 00000000000..31ecb1fcb9d --- /dev/null +++ b/docker/pii-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Starts uvicorn as PID 1 (exec) so SIGTERM reaches it directly. Quoted expansions +# make a malformed PII_WORKERS fail uvicorn's arg parsing, not the shell. +set -- --host 0.0.0.0 --port 5001 --workers "${PII_WORKERS:-1}" --timeout-graceful-shutdown 300 + +# Worker memory grows with uptime, so with a supervisor (more than one worker) each +# worker exits after a jittered request count, health checks included, and is +# respawned. A single worker has no supervisor to respawn it, so it never recycles. +if [ "${PII_WORKERS:-1}" -gt 1 ] 2>/dev/null; then + set -- "$@" --limit-max-requests "${PII_MAX_REQUESTS:-4000}" \ + --limit-max-requests-jitter "${PII_MAX_REQUESTS_JITTER:-2000}" +fi + +exec uvicorn server:app "$@" diff --git a/docker/pii.Dockerfile b/docker/pii.Dockerfile index 4301db62900..677fd9c1b6f 100644 --- a/docker/pii.Dockerfile +++ b/docker/pii.Dockerfile @@ -39,6 +39,7 @@ RUN groupadd -g 1001 pii && \ chown -R pii:pii /app COPY --chown=pii:pii apps/pii/server.py ./ +COPY --chown=pii:pii --chmod=755 docker/pii-entrypoint.sh ./ USER pii @@ -63,7 +64,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \ # Worker count is env-driven so ONE image scales per task size: set PII_WORKERS to # the task's vCPU count (each worker loads the models independently, ~3 GB each, so # size task memory β‰ˆ PII_WORKERS Γ— 3 GB + overhead). Defaults to 1 for local/small. -# `sh -c exec` expands the env var while keeping uvicorn as PID 1 for clean SIGTERM. -# Quote the expansion so a malformed PII_WORKERS fails uvicorn arg-parsing rather -# than being interpreted by the shell. -CMD ["sh", "-c", "exec uvicorn server:app --host 0.0.0.0 --port 5001 --workers \"${PII_WORKERS:-1}\""] +CMD ["./pii-entrypoint.sh"] diff --git a/knip.jsonc b/knip.jsonc index a5142e18537..640b77d6370 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -48,13 +48,23 @@ // Generated contracts mirror their source of truth; regenerating them must not // trip the unused-export ratchet, and hand edits would be overwritten. "lib/mothership/generated/**": ["exports", "types", "duplicates"], - // Consumed only by the stacked Project file PRs: the rendered-content contracts by #9017, - // the client aliases by #9034. Remove each entry once its consumer merges. + // Client aliases consumed only by the stacked Project file PR #9034. Remove each entry once + // its consumer merges. "lib/api/contracts/projects.ts": ["types"], - "lib/api/contracts/project-files.ts": ["exports", "types"], + "lib/api/contracts/project-files.ts": ["types"], "lib/api/contracts/project-file-folders.ts": ["types"], "lib/api/contracts/project-file-lifecycle.ts": ["types"], "sandboxes/index.ts": ["files"], + "lib/api/contracts/file-copy.ts": ["exports", "types"], + "lib/api/contracts/file-copy-input.ts": ["exports", "types"], + // API contract conventions require named boundary schemas and wire aliases before client adoption. + "lib/api/contracts/project-file-downloads.ts": ["exports", "types"], + "lib/api/contracts/project-file-shares.ts": ["exports", "types"], + "lib/api/contracts/v2/project-file-shares.ts": ["exports", "types"], + "lib/api/contracts/project-file-extraction.ts": ["exports", "types"], + "lib/api/contracts/v2/project-file-downloads.ts": ["exports", "types"], + "lib/api/contracts/v2/project-file-extraction.ts": ["exports", "types"], + "lib/api/contracts/v2/project-file-search.ts": ["exports", "types"], "components/mcp/index.ts": ["files"], "triggers/quickbooks/index.ts": ["files"], "lib/api/contracts/selectors/index.ts": ["files"], diff --git a/packages/db/migrations/0430_tool_execution_attempts.sql b/packages/db/migrations/0430_tool_execution_attempts.sql new file mode 100644 index 00000000000..a2dfadf3610 --- /dev/null +++ b/packages/db/migrations/0430_tool_execution_attempts.sql @@ -0,0 +1 @@ +ALTER TABLE "copilot_async_tool_calls" ADD COLUMN "execution_attempts" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/db/migrations/meta/0430_snapshot.json b/packages/db/migrations/meta/0430_snapshot.json new file mode 100644 index 00000000000..f7914fb981b --- /dev/null +++ b/packages/db/migrations/meta/0430_snapshot.json @@ -0,0 +1,31432 @@ +{ + "id": "5a367f45-20c0-4cd5-8a48-115ad19079f4", + "prevId": "db459767-6689-4031-ba6c-3e45d0e8f7e6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_revoked_at": { + "name": "refresh_revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_revoked_code": { + "name": "refresh_revoked_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_revoked_token_hash": { + "name": "refresh_revoked_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_memory_turn": { + "name": "agent_memory_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_order": { + "name": "execution_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_memory_turn_invocation_unique": { + "name": "agent_memory_turn_invocation_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "node_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_memory_turn_workflow_idx": { + "name": "agent_memory_turn_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_memory_turn_memory_id_memory_id_fk": { + "name": "agent_memory_turn_memory_id_memory_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memory_turn_workflow_id_workflow_id_fk": { + "name": "agent_memory_turn_workflow_id_workflow_id_fk", + "tableFrom": "agent_memory_turn", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changelog_change": { + "name": "changelog_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "release_id": { + "name": "release_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "changelog_change_release_position_unique": { + "name": "changelog_change_release_position_unique", + "columns": [ + { + "expression": "release_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "changelog_change_release_id_changelog_release_id_fk": { + "name": "changelog_change_release_id_changelog_release_id_fk", + "tableFrom": "changelog_change", + "tableTo": "changelog_release", + "columnsFrom": ["release_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "changelog_change_workflow_id_workflow_id_fk": { + "name": "changelog_change_workflow_id_workflow_id_fk", + "tableFrom": "changelog_change", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "changelog_change_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "changelog_change_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "changelog_change", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "changelog_change_chat_id_copilot_chats_id_fk": { + "name": "changelog_change_chat_id_copilot_chats_id_fk", + "tableFrom": "changelog_change", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.changelog_release": { + "name": "changelog_release", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_major": { + "name": "version_major", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_minor": { + "name": "version_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_patch": { + "name": "version_patch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bump_reason": { + "name": "bump_reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_file_id": { + "name": "body_file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "published_at": { + "name": "published_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "changelog_release_workspace_version_unique": { + "name": "changelog_release_workspace_version_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_major", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_minor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_patch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changelog_release_body_file_unique": { + "name": "changelog_release_body_file_unique", + "columns": [ + { + "expression": "body_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "changelog_release_workspace_published_idx": { + "name": "changelog_release_workspace_published_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "changelog_release_workspace_id_workspace_id_fk": { + "name": "changelog_release_workspace_id_workspace_id_fk", + "tableFrom": "changelog_release", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "changelog_release_body_file_id_workspace_files_id_fk": { + "name": "changelog_release_body_file_id_workspace_files_id_fk", + "tableFrom": "changelog_release", + "tableTo": "workspace_files", + "columnsFrom": ["body_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "changelog_release_created_by_user_id_fk": { + "name": "changelog_release_created_by_user_id_fk", + "tableFrom": "changelog_release", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "changelog_release_updated_by_user_id_fk": { + "name": "changelog_release_updated_by_user_id_fk", + "tableFrom": "changelog_release", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "changelog_release_version_check": { + "name": "changelog_release_version_check", + "value": "\"changelog_release\".\"version_major\" >= 0 AND \"changelog_release\".\"version_minor\" >= 0 AND \"changelog_release\".\"version_patch\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_user_id_idx": { + "name": "chat_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_credential_token": { + "name": "client_credential_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_digest": { + "name": "access_token_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "client_credential_token_expires_at_idx": { + "name": "client_credential_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"client_credential_token\".\"access_token_digest\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_credential_token_access_token_digest_idx": { + "name": "client_credential_token_access_token_digest_idx", + "columns": [ + { + "expression": "access_token_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"client_credential_token\".\"access_token_digest\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_requested_at": { + "name": "permission_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pickup_deadline_at": { + "name": "pickup_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "persist_seq": { + "name": "persist_seq", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "nextval('copilot_async_tool_calls_persist_seq')" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "browser_download_started_at": { + "name": "browser_download_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_started_at": { + "name": "execution_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_settled_at": { + "name": "execution_settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "execution_owner_token": { + "name": "execution_owner_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_lease_expires_at": { + "name": "execution_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_revoked_at": { + "name": "execution_revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_attempts": { + "name": "execution_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "client_workflow_execution_id": { + "name": "client_workflow_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_processes": { + "name": "sandbox_processes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "memory_space_id": { + "name": "memory_space_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_key": { + "name": "external_conversation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_conversation_metadata": { + "name": "external_conversation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_organization_id_idx": { + "name": "copilot_chats_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_external_conversation_unique": { + "name": "copilot_chats_external_conversation_unique", + "columns": [ + { + "expression": "external_conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_chats\".\"external_conversation_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_org_created_idx": { + "name": "copilot_chats_user_org_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_organization_id_organization_id_fk": { + "name": "copilot_chats_organization_id_organization_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "copilot_chats_owner_check": { + "name": "copilot_chats_owner_check", + "value": "num_nonnulls(\"copilot_chats\".\"workspace_id\", \"copilot_chats\".\"organization_id\") <= 1" + }, + "copilot_chats_organization_workflow_check": { + "name": "copilot_chats_organization_workflow_check", + "value": "\"copilot_chats\".\"organization_id\" IS NULL OR \"copilot_chats\".\"workflow_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "display_content": { + "name": "display_content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_organization_request_stops": { + "name": "copilot_organization_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_organization_request_stops_user_id_user_id_fk": { + "name": "copilot_organization_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_organization_request_stops_organization_id_organization_id_fk": { + "name": "copilot_organization_request_stops_organization_id_organization_id_fk", + "tableFrom": "copilot_organization_request_stops", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_organization_request_stops_user_id_organization_id_stream_id_pk": { + "name": "copilot_organization_request_stops_user_id_organization_id_stream_id_pk", + "columns": ["user_id", "organization_id", "stream_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_request_stops": { + "name": "copilot_request_stops", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "copilot_request_stops_user_id_user_id_fk": { + "name": "copilot_request_stops_user_id_user_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_request_stops_workspace_id_workspace_id_fk": { + "name": "copilot_request_stops_workspace_id_workspace_id_fk", + "tableFrom": "copilot_request_stops", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "copilot_request_stops_user_id_workspace_id_stream_id_pk": { + "name": "copilot_request_stops_user_id_workspace_id_stream_id_pk", + "columns": ["user_id", "workspace_id", "stream_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_execution_version": { + "name": "tool_execution_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_admission_closed_at": { + "name": "tool_admission_closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "desktop_device_id": { + "name": "desktop_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_started_at_idx": { + "name": "copilot_runs_chat_started_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_desktop_device_started_at_idx": { + "name": "copilot_runs_desktop_device_started_at_idx", + "columns": [ + { + "expression": "desktop_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_runs\".\"desktop_device_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_organization_id_organization_id_fk": { + "name": "copilot_runs_organization_id_organization_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_desktop_device_id_desktop_devices_id_fk": { + "name": "copilot_runs_desktop_device_id_desktop_devices_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "desktop_devices", + "columnsFrom": ["desktop_device_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_service_usage": { + "name": "copilot_service_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(12, 8)", + "primaryKey": false, + "notNull": false + }, + "worker_origin": { + "name": "worker_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_service_usage_pending_idx": { + "name": "copilot_service_usage_pending_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "delivered_at IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_personal_token": { + "name": "encrypted_personal_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_oauth_config_version": { + "name": "mcp_oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_organization_id_idx": { + "name": "credential_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_organization_account_unique": { + "name": "credential_organization_account_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_org_personal_token_unique": { + "name": "credential_org_personal_token_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_personal_token_identity_unique": { + "name": "credential_personal_token_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'personal_token'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_organization_id_organization_id_fk": { + "name": "credential_organization_id_organization_id_fk", + "tableFrom": "credential", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_slack_app_id_slack_app_id_fk": { + "name": "credential_slack_app_id_slack_app_id_fk", + "tableFrom": "credential", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_owner_check": { + "name": "credential_owner_check", + "value": "num_nonnulls(\"credential\".\"workspace_id\", \"credential\".\"organization_id\") = 1" + }, + "credential_organization_type_check": { + "name": "credential_organization_type_check", + "value": "\"credential\".\"organization_id\" IS NULL OR \"credential\".\"type\" IN ('oauth', 'managed_oauth', 'managed_mcp', 'service_account', 'personal_token')" + }, + "credential_personal_token_source_check": { + "name": "credential_personal_token_source_check", + "value": "(type::text <> 'personal_token') OR (\n created_by IS NOT NULL\n AND provider_id IS NOT NULL\n AND provider_id = 'gitlab'\n AND provider_subject_id IS NOT NULL\n AND provider_tenant_id IS NOT NULL\n AND encrypted_personal_token IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND account_id IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND authorization_app_id IS NULL\n AND encrypted_oauth_token_set IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_organization_id_idx": { + "name": "credential_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_organization_unique": { + "name": "credential_group_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_unique": { + "name": "credential_group_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_organization_id_organization_id_fk": { + "name": "credential_group_organization_id_organization_id_fk", + "tableFrom": "credential_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_owner_check": { + "name": "credential_group_owner_check", + "value": "num_nonnulls(\"credential_group\".\"workspace_id\", \"credential_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_user_unique": { + "name": "credential_group_enrollment_group_user_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential_group_enrollment\".\"user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_user_id_idx": { + "name": "credential_group_enrollment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_user_id_user_id_fk": { + "name": "credential_group_enrollment_user_id_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard": { + "name": "dashboard", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dashboard_workspace_id_unique": { + "name": "dashboard_workspace_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboard_workspace_id_workspace_id_fk": { + "name": "dashboard_workspace_id_workspace_id_fk", + "tableFrom": "dashboard", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dashboard_created_by_user_id_fk": { + "name": "dashboard_created_by_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "dashboard_updated_by_user_id_fk": { + "name": "dashboard_updated_by_user_id_fk", + "tableFrom": "dashboard", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.desktop_devices": { + "name": "desktop_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "desktop_devices_user_id_idx": { + "name": "desktop_devices_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "desktop_devices_session_id_idx": { + "name": "desktop_devices_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "desktop_devices_user_id_user_id_fk": { + "name": "desktop_devices_user_id_user_id_fk", + "tableFrom": "desktop_devices", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "desktop_devices_session_id_session_id_fk": { + "name": "desktop_devices_session_id_session_id_fk", + "tableFrom": "desktop_devices", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_recovery_after": { + "name": "processing_recovery_after", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "acl_requirements": { + "name": "acl_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "acl_verified_at": { + "name": "acl_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_seen_at": { + "name": "source_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_uploaded_by_idx": { + "name": "doc_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"uploaded_by\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_kb_source_modified_idx": { + "name": "doc_kb_source_modified_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_modified_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_processing_recovery_idx": { + "name": "doc_connector_processing_recovery_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"content_hash\" IS NOT NULL AND \"document\".\"storage_key\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_connector_processing_status_idx": { + "name": "doc_connector_processing_status_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"processing_status\" IN ('pending', 'processing', 'failed') AND \"document\".\"connector_id\" IS NOT NULL AND \"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_source_lookup_idx": { + "name": "doc_connector_source_lookup_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_reconciliation_v2_idx": { + "name": "doc_connector_reconciliation_v2_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_tombstone_idx": { + "name": "doc_connector_tombstone_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NULL AND (\"document\".\"deleted_at\" IS NOT NULL OR \"document\".\"content_hash\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_live_idx": { + "name": "doc_connector_live_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag1_lower_idx": { + "name": "doc_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag2_lower_idx": { + "name": "doc_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag3_lower_idx": { + "name": "doc_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag4_lower_idx": { + "name": "doc_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag5_lower_idx": { + "name": "doc_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag6_lower_idx": { + "name": "doc_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_tag7_lower_idx": { + "name": "doc_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag1_lower_idx": { + "name": "emb_kb_tag1_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag1\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag2_lower_idx": { + "name": "emb_kb_tag2_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag2\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag3_lower_idx": { + "name": "emb_kb_tag3_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag3\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag4_lower_idx": { + "name": "emb_kb_tag4_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag4\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag5_lower_idx": { + "name": "emb_kb_tag5_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag5\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag6_lower_idx": { + "name": "emb_kb_tag6_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag6\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_tag7_lower_idx": { + "name": "emb_kb_tag7_lower_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"tag7\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": { + "fastupdate": "off" + } + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_search": { + "name": "embedding_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "binary": { + "name": "binary", + "type": "bit(1536)", + "primaryKey": false, + "notNull": false + }, + "binary_384": { + "name": "binary_384", + "type": "bit(384)", + "primaryKey": false, + "notNull": false + }, + "binary_768": { + "name": "binary_768", + "type": "bit(768)", + "primaryKey": false, + "notNull": false + }, + "binary_1024": { + "name": "binary_1024", + "type": "bit(1024)", + "primaryKey": false, + "notNull": false + }, + "binary_3072": { + "name": "binary_3072", + "type": "bit(3072)", + "primaryKey": false, + "notNull": false + }, + "vector": { + "name": "vector", + "type": "halfvec(1536)", + "primaryKey": false, + "notNull": false + }, + "vector_384": { + "name": "vector_384", + "type": "halfvec(384)", + "primaryKey": false, + "notNull": false + }, + "vector_512": { + "name": "vector_512", + "type": "halfvec(512)", + "primaryKey": false, + "notNull": false + }, + "vector_768": { + "name": "vector_768", + "type": "halfvec(768)", + "primaryKey": false, + "notNull": false + }, + "vector_1024": { + "name": "vector_1024", + "type": "halfvec(1024)", + "primaryKey": false, + "notNull": false + }, + "vector_3072": { + "name": "vector_3072", + "type": "halfvec(3072)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "embedding_search_kb_idx": { + "name": "embedding_search_kb_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_search_document_lookup_idx": { + "name": "embedding_search_document_lookup_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"embedding_search\".\"enabled\"", + "concurrently": true, + "method": "btree", + "with": {} + }, + "embedding_search_cosine_hnsw_idx": { + "name": "embedding_search_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_512_cosine_hnsw_idx": { + "name": "embedding_search_512_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_512", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_384_cosine_hnsw_idx": { + "name": "embedding_search_384_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_768_cosine_hnsw_idx": { + "name": "embedding_search_768_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_1024_cosine_hnsw_idx": { + "name": "embedding_search_1024_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_search_3072_cosine_hnsw_idx": { + "name": "embedding_search_3072_cosine_hnsw_idx", + "columns": [ + { + "expression": "vector_3072", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "halfvec_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + } + }, + "foreignKeys": { + "embedding_search_id_embedding_id_fk": { + "name": "embedding_search_id_embedding_id_fk", + "tableFrom": "embedding_search", + "tableTo": "embedding", + "columnsFrom": ["id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_search_width_check": { + "name": "embedding_search_width_check", + "value": "num_nonnulls(\"binary\", \"binary_384\", \"binary_768\", \"binary_1024\", \"binary_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workspace_id_idx": { + "name": "execution_large_values_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_search_dependency": { + "name": "file_search_dependency", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "file_search_dependency_file_idx": { + "name": "file_search_dependency_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "file_search_dependency_owner_idx": { + "name": "file_search_dependency_owner_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "file_search_dependency_build_id_workspace_file_search_build_id_fk": { + "name": "file_search_dependency_build_id_workspace_file_search_build_id_fk", + "tableFrom": "file_search_dependency", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "file_search_dependency_build_id_file_id_pk": { + "name": "file_search_dependency_build_id_file_id_pk", + "columns": ["build_id", "file_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_search_dispatch_queue": { + "name": "file_search_dispatch_queue", + "schema": "", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "file_search_dispatch_schedule_idx": { + "name": "file_search_dispatch_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "file_search_dispatch_queue_entity_type_entity_id_pk": { + "name": "file_search_dispatch_queue_entity_type_entity_id_pk", + "columns": ["entity_type", "entity_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_dispatch_owner_check": { + "name": "file_search_dispatch_owner_check", + "value": "\"file_search_dispatch_queue\".\"entity_type\" IN ('workspace', 'project') AND length(\"file_search_dispatch_queue\".\"entity_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_project_id_idx": { + "name": "folder_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "folder_project_resource_parent_name_active_unique": { + "name": "folder_project_resource_parent_name_active_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL AND \"folder\".\"project_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_project_id_project_id_fk": { + "name": "folder_project_id_project_id_fk", + "tableFrom": "folder", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "folder_workspace_parent_owner_fk": { + "name": "folder_workspace_parent_owner_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id", "workspace_id", "resource_type"], + "columnsTo": ["id", "workspace_id", "resource_type"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "folder_project_parent_owner_fk": { + "name": "folder_project_parent_owner_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id", "project_id", "resource_type"], + "columnsTo": ["id", "project_id", "resource_type"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "folder_workspace_owner_type_unique": { + "name": "folder_workspace_owner_type_unique", + "nullsNotDistinct": false, + "columns": ["id", "workspace_id", "resource_type"] + }, + "folder_project_owner_type_unique": { + "name": "folder_project_owner_type_unique", + "nullsNotDistinct": false, + "columns": ["id", "project_id", "resource_type"] + } + }, + "policies": {}, + "checkConstraints": { + "folder_owner_check": { + "name": "folder_owner_check", + "value": "num_nonnulls(\"folder\".\"workspace_id\", \"folder\".\"project_id\") = 1 AND (\"folder\".\"project_id\" IS NULL OR \"folder\".\"resource_type\" = 'file')" + } + }, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_search_index": { + "name": "is_search_index", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_organization_id_idx": { + "name": "kb_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_search_index_unique": { + "name": "kb_organization_search_index_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_organization_name_active_unique": { + "name": "kb_organization_name_active_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_search_index_unique": { + "name": "kb_workspace_search_index_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"is_search_index\" = true AND \"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_organization_id_organization_id_fk": { + "name": "knowledge_base_organization_id_organization_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kb_owner_check": { + "name": "kb_owner_check", + "value": "num_nonnulls(\"knowledge_base\".\"workspace_id\", \"knowledge_base\".\"organization_id\") = 1" + }, + "kb_organization_search_index_check": { + "name": "kb_organization_search_index_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"is_search_index\"" + }, + "kb_organization_folder_check": { + "name": "kb_organization_folder_check", + "value": "\"knowledge_base\".\"organization_id\" IS NULL OR \"knowledge_base\".\"folder_id\" IS NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image_removal_pending": { + "name": "image_removal_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "member_tombstone_cursor": { + "name": "member_tombstone_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "member_resurrection_cursor": { + "name": "member_resurrection_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "directory_checkpoint": { + "name": "directory_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "next_directory_sync_at": { + "name": "next_directory_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detached_at": { + "name": "detached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "detach_reserved_bytes": { + "name": "detach_reserved_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_directory_sync_due_idx": { + "name": "kc_directory_sync_due_idx", + "columns": [ + { + "expression": "next_directory_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'admin' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewed_at": { + "name": "scope_renewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_cursor": { + "name": "scope_renewal_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_renewal_started_at": { + "name": "scope_renewal_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_checkpoint": { + "name": "listing_checkpoint", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_organization_id_idx": { + "name": "kcm_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_organization_id_organization_id_fk": { + "name": "knowledge_connector_member_organization_id_organization_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_owner_check": { + "name": "kcm_owner_check", + "value": "num_nonnulls(\"knowledge_connector_member\".\"workspace_id\", \"knowledge_connector_member\".\"organization_id\") = 1" + }, + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "processing_dispatch_failed": { + "name": "processing_dispatch_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_renewed": { + "name": "observations_renewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'partial', 'completed', 'failed')" + }, + "kcmsl_database_failure_class_check": { + "name": "kcmsl_database_failure_class_check", + "value": "\"knowledge_connector_member_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_partition": { + "name": "knowledge_connector_partition", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "partition_key": { + "name": "partition_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_id": { + "name": "generation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "retry_at": { + "name": "retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_served_at": { + "name": "last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure": { + "name": "failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "permission_cursor": { + "name": "permission_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_attempts": { + "name": "permission_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "permission_retry_at": { + "name": "permission_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permission_last_served_at": { + "name": "permission_last_served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_started_at": { + "name": "permission_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permission_failure": { + "name": "permission_failure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcp_content_due_idx": { + "name": "kcp_content_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcp_permission_due_idx": { + "name": "kcp_permission_due_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_last_served_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_partition_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_partition_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_partition", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcp_pk": { + "name": "kcp_pk", + "columns": ["connector_id", "partition_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcp_partition_key_check": { + "name": "kcp_partition_key_check", + "value": "octet_length(\"knowledge_connector_partition\".\"partition_key\") BETWEEN 1 AND 1024" + }, + "kcp_context_check": { + "name": "kcp_context_check", + "value": "jsonb_typeof(\"knowledge_connector_partition\".\"context\") = 'object' AND octet_length(\"knowledge_connector_partition\".\"context\"::text) <= 16384" + }, + "kcp_status_check": { + "name": "kcp_status_check", + "value": "\"knowledge_connector_partition\".\"status\" IN ('pending', 'complete', 'blocked')" + }, + "kcp_cursor_check": { + "name": "kcp_cursor_check", + "value": "(\"knowledge_connector_partition\".\"cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"cursor\") <= 393216) AND (\"knowledge_connector_partition\".\"permission_cursor\" IS NULL OR octet_length(\"knowledge_connector_partition\".\"permission_cursor\") <= 393216)" + }, + "kcp_attempts_check": { + "name": "kcp_attempts_check", + "value": "\"knowledge_connector_partition\".\"attempts\" >= 0 AND \"knowledge_connector_partition\".\"permission_attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_grant": { + "name": "knowledge_connector_permission_grant", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kcpg_subject_idx": { + "name": "kcpg_subject_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kcpg_snapshot_fk": { + "name": "kcpg_snapshot_fk", + "tableFrom": "knowledge_connector_permission_grant", + "tableTo": "knowledge_connector_permission_snapshot", + "columnsFrom": ["connector_id"], + "columnsTo": ["connector_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "kcpg_pk": { + "name": "kcpg_pk", + "columns": ["connector_id", "group_key", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcpg_group_check": { + "name": "kcpg_group_check", + "value": "length(\"knowledge_connector_permission_grant\".\"group_key\") BETWEEN 1 AND 255" + }, + "kcpg_subject_check": { + "name": "kcpg_subject_check", + "value": "\"knowledge_connector_permission_grant\".\"subject_token\" ~ '^u:[^[:space:]A-Z]+@[^[:space:]A-Z]+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_permission_snapshot": { + "name": "knowledge_connector_permission_snapshot", + "schema": "", + "columns": { + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "kcps_connector_fk": { + "name": "kcps_connector_fk", + "tableFrom": "knowledge_connector_permission_snapshot", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcps_revision_check": { + "name": "kcps_revision_check", + "value": "\"knowledge_connector_permission_snapshot\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "listed_count": { + "name": "listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_failure_class": { + "name": "database_failure_class", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_terminal_started_at_idx": { + "name": "kcsl_terminal_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" IN ('completed', 'failed')", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcsl_database_failure_class_check": { + "name": "kcsl_database_failure_class_check", + "value": "\"knowledge_connector_sync_log\".\"database_failure_class\" IN ('capacity', 'conflict', 'connection')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_external_directory": { + "name": "knowledge_external_directory", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_sync_at": { + "name": "last_complete_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ked_organization_id_idx": { + "name": "ked_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_workspace_identity_unique": { + "name": "ked_workspace_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ked_organization_identity_unique": { + "name": "ked_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_directory_workspace_id_workspace_id_fk": { + "name": "knowledge_external_directory_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_external_directory_organization_id_organization_id_fk": { + "name": "knowledge_external_directory_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_directory", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ked_owner_check": { + "name": "ked_owner_check", + "value": "num_nonnulls(\"knowledge_external_directory\".\"workspace_id\", \"knowledge_external_directory\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group": { + "name": "knowledge_external_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_group_id": { + "name": "external_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keg_organization_id_idx": { + "name": "keg_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_identity_unique": { + "name": "keg_organization_identity_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_organization_synced_idx": { + "name": "keg_organization_synced_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_identity_unique": { + "name": "keg_identity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "keg_workspace_synced_idx": { + "name": "keg_workspace_synced_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_synced_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_external_group_organization_id_organization_id_fk": { + "name": "knowledge_external_group_organization_id_organization_id_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "keg_workspace_fk": { + "name": "keg_workspace_fk", + "tableFrom": "knowledge_external_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "keg_owner_check": { + "name": "keg_owner_check", + "value": "num_nonnulls(\"knowledge_external_group\".\"workspace_id\", \"knowledge_external_group\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_external_group_member": { + "name": "knowledge_external_group_member", + "schema": "", + "columns": { + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kegm_subject_token_idx": { + "name": "kegm_subject_token_idx", + "columns": [ + { + "expression": "subject_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kegm_group_fk": { + "name": "kegm_group_fk", + "tableFrom": "knowledge_external_group_member", + "tableTo": "knowledge_external_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_external_group_member_group_id_subject_token_pk": { + "name": "knowledge_external_group_member_group_id_subject_token_pk", + "columns": ["group_id", "subject_token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_projection_dirty": { + "name": "knowledge_projection_dirty", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content": { + "name": "content", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marked_at": { + "name": "marked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "knowledge_projection_dirty_marked_at_idx": { + "name": "knowledge_projection_dirty_marked_at_idx", + "columns": [ + { + "expression": "marked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_projection_dirty_document_id_document_id_fk": { + "name": "knowledge_projection_dirty_document_id_document_id_fk", + "tableFrom": "knowledge_projection_dirty", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_organization_id_organization_id_fk": { + "name": "mcp_server_oauth_organization_id_organization_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_server_oauth_owner_check": { + "name": "mcp_server_oauth_owner_check", + "value": "num_nonnulls(\"mcp_server_oauth\".\"workspace_id\", \"mcp_server_oauth\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config_version": { + "name": "oauth_config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_organization_id_idx": { + "name": "mcp_servers_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_organization_id_organization_id_fk": { + "name": "mcp_servers_organization_id_organization_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_owner_check": { + "name": "mcp_servers_owner_check", + "value": "num_nonnulls(\"mcp_servers\".\"workspace_id\", \"mcp_servers\".\"organization_id\") = 1" + }, + "mcp_servers_organization_managed_check": { + "name": "mcp_servers_organization_managed_check", + "value": "\"mcp_servers\".\"organization_id\" IS NULL OR \"mcp_servers\".\"credential_group_id\" IS NOT NULL" + }, + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "storage_version": { + "name": "storage_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "encrypted_context_summary": { + "name": "encrypted_context_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_artifact": { + "name": "memory_artifact", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "memory_artifact_key_idx": { + "name": "memory_artifact_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_artifact_memory_id_memory_id_fk": { + "name": "memory_artifact_memory_id_memory_id_fk", + "tableFrom": "memory_artifact", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_artifact_key_execution_large_values_key_fk": { + "name": "memory_artifact_key_execution_large_values_key_fk", + "tableFrom": "memory_artifact", + "tableTo": "execution_large_values", + "columnsFrom": ["key"], + "columnsTo": ["key"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memory_artifact_memory_id_key_pk": { + "name": "memory_artifact_memory_id_key_pk", + "columns": ["memory_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_item": { + "name": "memory_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "memory_item_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "append_key": { + "name": "append_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_status": { + "name": "provenance_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance_entries": { + "name": "provenance_entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_item_append_unique": { + "name": "memory_item_append_unique", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "append_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_item_sequence_idx": { + "name": "memory_item_sequence_idx", + "columns": [ + { + "expression": "memory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_item_memory_id_memory_id_fk": { + "name": "memory_item_memory_id_memory_id_fk", + "tableFrom": "memory_item", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_item_kind_check": { + "name": "memory_item_kind_check", + "value": "\"memory_item\".\"kind\" IN ('message', 'exchange')" + }, + "memory_item_provenance_status_check": { + "name": "memory_item_provenance_status_check", + "value": "\"memory_item\".\"provenance_status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_benchmark_runs": { + "name": "mothership_benchmark_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "benchmark_id": { + "name": "benchmark_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "evaluation_key": { + "name": "evaluation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "correct": { + "name": "correct", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "automatic_correct": { + "name": "automatic_correct", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_benchmark_runs_benchmark_created_idx": { + "name": "mothership_benchmark_runs_benchmark_created_idx", + "columns": [ + { + "expression": "benchmark_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_benchmark_runs_benchmark_id_mothership_benchmarks_id_fk": { + "name": "mothership_benchmark_runs_benchmark_id_mothership_benchmarks_id_fk", + "tableFrom": "mothership_benchmark_runs", + "tableTo": "mothership_benchmarks", + "columnsFrom": ["benchmark_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mothership_benchmark_runs_score_check": { + "name": "mothership_benchmark_runs_score_check", + "value": "\"mothership_benchmark_runs\".\"total\" > 0 AND \"mothership_benchmark_runs\".\"correct\" >= 0 AND \"mothership_benchmark_runs\".\"correct\" <= \"mothership_benchmark_runs\".\"total\"" + }, + "mothership_benchmark_runs_version_check": { + "name": "mothership_benchmark_runs_version_check", + "value": "\"mothership_benchmark_runs\".\"version\" > 0" + }, + "mothership_benchmark_runs_automatic_score_check": { + "name": "mothership_benchmark_runs_automatic_score_check", + "value": "\"mothership_benchmark_runs\".\"automatic_correct\" >= 0 AND \"mothership_benchmark_runs\".\"automatic_correct\" <= \"mothership_benchmark_runs\".\"total\"" + } + }, + "isRLSEnabled": false + }, + "public.mothership_benchmarks": { + "name": "mothership_benchmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_as_user_id": { + "name": "run_as_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "running_stage": { + "name": "running_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "planner_chat_id": { + "name": "planner_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_benchmarks_owner_created_idx": { + "name": "mothership_benchmarks_owner_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mothership_benchmarks_workspace_idx": { + "name": "mothership_benchmarks_workspace_idx", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_benchmarks_organization_id_organization_id_fk": { + "name": "mothership_benchmarks_organization_id_organization_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_benchmarks_user_id_user_id_fk": { + "name": "mothership_benchmarks_user_id_user_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_benchmarks_run_as_user_id_user_id_fk": { + "name": "mothership_benchmarks_run_as_user_id_user_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "user", + "columnsFrom": ["run_as_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_benchmarks_source_workspace_id_workspace_id_fk": { + "name": "mothership_benchmarks_source_workspace_id_workspace_id_fk", + "tableFrom": "mothership_benchmarks", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mothership_benchmarks_version_check": { + "name": "mothership_benchmarks_version_check", + "value": "\"mothership_benchmarks\".\"version\" > 0" + }, + "mothership_benchmarks_attempt_check": { + "name": "mothership_benchmarks_attempt_check", + "value": "(\"mothership_benchmarks\".\"running_stage\" IS NULL AND \"mothership_benchmarks\".\"attempt_id\" IS NULL AND \"mothership_benchmarks\".\"lease_expires_at\" IS NULL) OR (\"mothership_benchmarks\".\"running_stage\" IS NOT NULL AND \"mothership_benchmarks\".\"running_stage\" IN ('distill', 'redact', 'plan', 'reconstruct', 'grade') AND \"mothership_benchmarks\".\"attempt_id\" IS NOT NULL AND \"mothership_benchmarks\".\"lease_expires_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_memory_selections": { + "name": "mothership_memory_selections", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_memory_selections_user_id_user_id_fk": { + "name": "mothership_memory_selections_user_id_user_id_fk", + "tableFrom": "mothership_memory_selections", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_memory_selections_organization_id_organization_id_fk": { + "name": "mothership_memory_selections_organization_id_organization_id_fk", + "tableFrom": "mothership_memory_selections", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_memory_selections_space_id_mothership_memory_spaces_id_fk": { + "name": "mothership_memory_selections_space_id_mothership_memory_spaces_id_fk", + "tableFrom": "mothership_memory_selections", + "tableTo": "mothership_memory_spaces", + "columnsFrom": ["space_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mothership_memory_selections_user_id_organization_id_pk": { + "name": "mothership_memory_selections_user_id_organization_id_pk", + "columns": ["user_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_memory_spaces": { + "name": "mothership_memory_spaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_memory_spaces_owner_idx": { + "name": "mothership_memory_spaces_owner_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_memory_spaces_user_id_user_id_fk": { + "name": "mothership_memory_spaces_user_id_user_id_fk", + "tableFrom": "mothership_memory_spaces", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_memory_spaces_organization_id_organization_id_fk": { + "name": "mothership_memory_spaces_organization_id_organization_id_fk", + "tableFrom": "mothership_memory_spaces", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_resource_effects": { + "name": "mothership_resource_effects", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effect_id": { + "name": "effect_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_resource_effects_chat_id_copilot_chats_id_fk": { + "name": "mothership_resource_effects_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_resource_effects", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mothership_resource_effects_chat_id_effect_id_pk": { + "name": "mothership_resource_effects_chat_id_effect_id_pk", + "columns": ["chat_id", "effect_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_access_token_search_resource_check": { + "name": "oauth_access_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_access_token\".\"scopes\")) OR \"oauth_access_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + }, + "oauth_refresh_token_search_resource_check": { + "name": "oauth_refresh_token_search_resource_check", + "value": "NOT ('search:read' = ANY(\"oauth_refresh_token\".\"scopes\")) OR \"oauth_refresh_token\".\"resource\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "require_sso": { + "name": "require_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_access_request_settings": { + "name": "organization_access_request_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "allow_requests": { + "name": "allow_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_access_request_settings_organization_id_organization_id_fk": { + "name": "organization_access_request_settings_organization_id_organization_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_access_request_settings_updated_by_user_id_fk": { + "name": "organization_access_request_settings_updated_by_user_id_fk", + "tableFrom": "organization_access_request_settings", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_history": { + "name": "organization_search_history", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "queries": { + "name": "queries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "organization_search_history_user_idx": { + "name": "organization_search_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_search_history_organization_id_organization_id_fk": { + "name": "organization_search_history_organization_id_organization_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_search_history_user_id_user_id_fk": { + "name": "organization_search_history_user_id_user_id_fk", + "tableFrom": "organization_search_history", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_history_organization_id_user_id_pk": { + "name": "organization_search_history_organization_id_user_id_pk", + "columns": ["organization_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_integration": { + "name": "organization_search_integration", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_search_integration_organization_id_organization_id_fk": { + "name": "organization_search_integration_organization_id_organization_id_fk", + "tableFrom": "organization_search_integration", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_search_integration_organization_id_connector_type_pk": { + "name": "organization_search_integration_organization_id_connector_type_pk", + "columns": ["organization_id", "connector_type"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_search_mcp_invocation": { + "name": "organization_search_mcp_invocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_search_mcp_invocation_org_created_idx": { + "name": "organization_search_mcp_invocation_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_search_mcp_invocation_user_idx": { + "name": "organization_search_mcp_invocation_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_search_mcp_invocation_org_fk": { + "name": "org_search_mcp_invocation_org_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_search_mcp_invocation_user_fk": { + "name": "org_search_mcp_invocation_user_fk", + "tableFrom": "organization_search_mcp_invocation", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_search_mcp_invocation_tool_check": { + "name": "organization_search_mcp_invocation_tool_check", + "value": "\"organization_search_mcp_invocation\".\"tool_name\" IN ('search', 'read_document', 'chat')" + }, + "organization_search_mcp_invocation_outcome_check": { + "name": "organization_search_mcp_invocation_outcome_check", + "value": "\"organization_search_mcp_invocation\".\"outcome\" IN ('success', 'error', 'cancelled', 'rate_limited')" + }, + "organization_search_mcp_invocation_duration_check": { + "name": "organization_search_mcp_invocation_duration_check", + "value": "\"organization_search_mcp_invocation\".\"duration_ms\" >= 0" + }, + "organization_search_mcp_invocation_client_name_check": { + "name": "organization_search_mcp_invocation_client_name_check", + "value": "length(\"organization_search_mcp_invocation\".\"client_name\") <= 256" + }, + "organization_search_mcp_invocation_auth_check": { + "name": "organization_search_mcp_invocation_auth_check", + "value": "(\"organization_search_mcp_invocation\".\"auth_kind\" = 'oauth_access_token' AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NOT NULL)\n OR (\"organization_search_mcp_invocation\".\"auth_kind\" IN ('personal_api_key', 'workspace_api_key') AND \"organization_search_mcp_invocation\".\"oauth_client_id\" IS NULL AND \"organization_search_mcp_invocation\".\"client_name\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.organization_secret": { + "name": "organization_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_secret_shared_unique": { + "name": "organization_secret_shared_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_member_unique": { + "name": "organization_secret_member_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_secret\".\"owner_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_secret_owner_idx": { + "name": "organization_secret_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_id_organization_secret_source_id_fk": { + "name": "organization_secret_source_id_organization_secret_source_id_fk", + "tableFrom": "organization_secret", + "tableTo": "organization_secret_source", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_secret_owner_user_id_user_id_fk": { + "name": "organization_secret_owner_user_id_user_id_fk", + "tableFrom": "organization_secret", + "tableTo": "user", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_secret_source": { + "name": "organization_secret_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_secret_source_org_unique": { + "name": "organization_secret_source_org_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_secret_source_organization_id_organization_id_fk": { + "name": "organization_secret_source_organization_id_organization_id_fk", + "tableFrom": "organization_secret_source", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_secret_source_mode_check": { + "name": "organization_secret_source_mode_check", + "value": "\"organization_secret_source\".\"mode\" IN ('organization', 'member')" + } + }, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_pending_type_available_idx": { + "name": "outbox_event_pending_type_available_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"outbox_event\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_organization_id_idx": { + "name": "pending_draft_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_org": { + "name": "pending_draft_user_provider_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_organization_id_organization_id_fk": { + "name": "pending_credential_draft_organization_id_organization_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_draft_owner_check": { + "name": "pending_draft_owner_check", + "value": "num_nonnulls(\"pending_credential_draft\".\"workspace_id\", \"pending_credential_draft\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.permission_access_request": { + "name": "permission_access_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requester_id": { + "name": "requester_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "target_label": { + "name": "target_label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decision_reason": { + "name": "decision_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decision": { + "name": "decision", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "permission_access_request_pending_unique": { + "name": "permission_access_request_pending_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"permission_access_request\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_org_queue_idx": { + "name": "permission_access_request_org_queue_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_access_request_requester_idx": { + "name": "permission_access_request_requester_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requester_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_access_request_organization_id_organization_id_fk": { + "name": "permission_access_request_organization_id_organization_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_requester_id_user_id_fk": { + "name": "permission_access_request_requester_id_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["requester_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_access_request_decided_by_user_id_fk": { + "name": "permission_access_request_decided_by_user_id_fk", + "tableFrom": "permission_access_request", + "tableTo": "user", + "columnsFrom": ["decided_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "permission_access_request_status_check": { + "name": "permission_access_request_status_check", + "value": "\"permission_access_request\".\"status\" in ('pending', 'fulfilled', 'declined', 'cancelled', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_scope_key": { + "name": "organization_scope_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END", + "type": "stored" + } + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_organization_archive_id_idx": { + "name": "project_organization_archive_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_owner_archive_id_idx": { + "name": "project_owner_archive_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_organization_id_organization_id_fk": { + "name": "project_organization_id_organization_id_fk", + "tableFrom": "project", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_id_organization_scope_unique": { + "name": "project_id_organization_scope_unique", + "nullsNotDistinct": false, + "columns": ["id", "organization_scope_key"] + } + }, + "policies": {}, + "checkConstraints": { + "project_name_length": { + "name": "project_name_length", + "value": "char_length(btrim(\"project\".\"name\")) BETWEEN 1 AND 100" + } + }, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_entity_id_idx": { + "name": "public_share_entity_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "public_share_entity_binding_check": { + "name": "public_share_entity_binding_check", + "value": "\n (\"public_share\".\"entity_type\" IS NULL AND \"public_share\".\"entity_id\" IS NULL AND \"public_share\".\"workspace_id\" IS NOT NULL)\n OR (\"public_share\".\"entity_type\" IS NOT NULL AND \"public_share\".\"entity_id\" IS NOT NULL AND char_length(\"public_share\".\"entity_id\") > 0\n AND ((\"public_share\".\"entity_type\" = 'workspace' AND \"public_share\".\"workspace_id\" IS NOT NULL AND \"public_share\".\"entity_id\" = \"public_share\".\"workspace_id\")\n OR (\"public_share\".\"entity_type\" = 'project' AND \"public_share\".\"workspace_id\" IS NULL AND \"public_share\".\"resource_type\" = 'file')))\n " + } + }, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_state": { + "name": "capacity_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_organization_id_idx": { + "name": "resource_policy_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_organization_id_organization_id_fk": { + "name": "resource_policy_organization_id_organization_id_fk", + "tableFrom": "resource_policy", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "resource_policy_owner_check": { + "name": "resource_policy_owner_check", + "value": "num_nonnulls(\"resource_policy\".\"workspace_id\", \"resource_policy\".\"organization_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "baseline_permission": { + "name": "baseline_permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_app": { + "name": "slack_app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_signing_secret": { + "name": "encrypted_signing_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "slack_app_organization_id_organization_id_fk": { + "name": "slack_app_organization_id_organization_id_fk", + "tableFrom": "slack_app", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_app_owner_check": { + "name": "slack_app_owner_check", + "value": "(\"slack_app\".\"kind\" = 'custom' AND \"slack_app\".\"organization_id\" IS NOT NULL) OR (\"slack_app\".\"kind\" = 'shared' AND \"slack_app\".\"organization_id\" IS NULL)" + }, + "slack_app_custom_credentials_check": { + "name": "slack_app_custom_credentials_check", + "value": "\"slack_app\".\"kind\" = 'shared' OR (\"slack_app\".\"client_id\" IS NOT NULL AND \"slack_app\".\"encrypted_client_secret\" IS NOT NULL AND \"slack_app\".\"encrypted_signing_secret\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.slack_search_installation": { + "name": "slack_search_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_app_id": { + "name": "slack_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "credential_version": { + "name": "credential_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_outcome": { + "name": "last_outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_event_at": { + "name": "last_event_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_installation_organization_idx": { + "name": "slack_search_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_credential_unique": { + "name": "slack_search_installation_credential_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_app_team_unique": { + "name": "slack_search_installation_app_team_unique", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_installation_active_team_unique": { + "name": "slack_search_installation_active_team_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_installation\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_installation_organization_id_organization_id_fk": { + "name": "slack_search_installation_organization_id_organization_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_credential_id_credential_id_fk": { + "name": "slack_search_installation_credential_id_credential_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_search_installation_slack_app_id_slack_app_id_fk": { + "name": "slack_search_installation_slack_app_id_slack_app_id_fk", + "tableFrom": "slack_search_installation", + "tableTo": "slack_app", + "columnsFrom": ["slack_app_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_search_turn": { + "name": "slack_search_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "slack_search_turn_ordinal_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_key": { + "name": "conversation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_search_turn_event_unique": { + "name": "slack_search_turn_event_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_pending_idx": { + "name": "slack_search_turn_pending_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_thread_idx": { + "name": "slack_search_turn_thread_idx", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_search_turn_active_thread_unique": { + "name": "slack_search_turn_active_thread_unique", + "columns": [ + { + "expression": "conversation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_search_turn\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_search_turn_installation_id_slack_search_installation_id_fk": { + "name": "slack_search_turn_installation_id_slack_search_installation_id_fk", + "tableFrom": "slack_search_turn", + "tableTo": "slack_search_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "primary_provider_id": { + "name": "primary_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_lower_idx": { + "name": "user_email_lower_idx", + "columns": [ + { + "expression": "lower(btrim(\"email\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_created_by_idx": { + "name": "user_table_def_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_changes": { + "name": "user_table_row_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "user_table_row_changes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_delta": { + "name": "row_delta", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_table_row_changes_table_id_idx": { + "name": "user_table_row_changes_table_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_row_changes_table_id_user_table_definitions_id_fk": { + "name": "user_table_row_changes_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_row_changes", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_created_by_idx": { + "name": "user_table_rows_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_rows\".\"created_by\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": { + "fastupdate": "off" + } + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_activity_level_idx": { + "name": "workflow_execution_logs_workspace_activity_level_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "total_duration_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_test": { + "name": "workflow_test", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_file_id": { + "name": "body_file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cases": { + "name": "cases", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_test_workspace_name_unique": { + "name": "workflow_test_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_test\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_test_body_file_unique": { + "name": "workflow_test_body_file_unique", + "columns": [ + { + "expression": "body_file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_test_workspace_id_workspace_id_fk": { + "name": "workflow_test_workspace_id_workspace_id_fk", + "tableFrom": "workflow_test", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_test_body_file_id_workspace_files_id_fk": { + "name": "workflow_test_body_file_id_workspace_files_id_fk", + "tableFrom": "workflow_test", + "tableTo": "workspace_files", + "columnsFrom": ["body_file_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_test_created_by_user_id_user_id_fk": { + "name": "workflow_test_created_by_user_id_user_id_fk", + "tableFrom": "workflow_test", + "tableTo": "user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_test_run": { + "name": "workflow_test_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "test_id": { + "name": "test_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed": { + "name": "failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped": { + "name": "skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ran_against": { + "name": "ran_against", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "progress": { + "name": "progress", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "triggered_by_actor": { + "name": "triggered_by_actor", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_test_run_test_version_started_idx": { + "name": "workflow_test_run_test_version_started_idx", + "columns": [ + { + "expression": "test_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_test_run_test_id_workflow_test_id_fk": { + "name": "workflow_test_run_test_id_workflow_test_id_fk", + "tableFrom": "workflow_test_run", + "tableTo": "workflow_test", + "columnsFrom": ["test_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_test_run_workspace_id_workspace_id_fk": { + "name": "workflow_test_run_workspace_id_workspace_id_fk", + "tableFrom": "workflow_test_run", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_test_run_triggered_by_user_id_user_id_fk": { + "name": "workflow_test_run_triggered_by_user_id_user_id_fk", + "tableFrom": "workflow_test_run", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_test_run_version_check": { + "name": "workflow_test_run_version_check", + "value": "\"workflow_test_run\".\"version\" IN ('draft', 'deployed')" + }, + "workflow_test_run_status_check": { + "name": "workflow_test_run_status_check", + "value": "\"workflow_test_run\".\"status\" IN ('running', 'passed', 'failed', 'error')" + }, + "workflow_test_run_completed_check": { + "name": "workflow_test_run_completed_check", + "value": "(\"workflow_test_run\".\"status\" = 'running') = (\"workflow_test_run\".\"completed_at\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_scope_key": { + "name": "organization_scope_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "CASE WHEN organization_id IS NULL THEN 'personal' ELSE 'organization:' || organization_id END", + "type": "stored" + } + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fork_sync_new_workflows_excluded": { + "name": "fork_sync_new_workflows_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_project_id_id_idx": { + "name": "workspace_project_id_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_project_id_project_id_fk": { + "name": "workspace_project_id_project_id_fk", + "tableFrom": "workspace", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_project_organization_fk": { + "name": "workspace_project_organization_fk", + "tableFrom": "workspace", + "tableTo": "project", + "columnsFrom": ["project_id", "organization_scope_key"], + "columnsTo": ["id", "organization_scope_key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_fork_project_fk": { + "name": "workspace_fork_project_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id", "project_id"], + "columnsTo": ["id", "project_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_id_project_unique": { + "name": "workspace_id_project_unique", + "nullsNotDistinct": false, + "columns": ["id", "project_id"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_uploaded_by_idx": { + "name": "workspace_file_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_entity_type": { + "name": "after_entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_entity_id": { + "name": "after_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_build": { + "name": "workspace_file_search_build", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_file_search_build_file_idx": { + "name": "workspace_file_search_build_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_build_cleanup_idx": { + "name": "workspace_file_search_build_cleanup_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_build\".\"expires_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_build_owner_check": { + "name": "file_search_build_owner_check", + "value": "(\"workspace_file_search_build\".\"entity_type\" IS NULL AND \"workspace_file_search_build\".\"entity_id\" IS NULL AND \"workspace_file_search_build\".\"workspace_id\" IS NOT NULL) OR (\"workspace_file_search_build\".\"entity_type\" = 'workspace' AND \"workspace_file_search_build\".\"entity_id\" = \"workspace_file_search_build\".\"workspace_id\") OR (\"workspace_file_search_build\".\"entity_type\" = 'project' AND length(\"workspace_file_search_build\".\"entity_id\") > 0 AND \"workspace_file_search_build\".\"workspace_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_chunk": { + "name": "workspace_file_search_chunk", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_start": { + "name": "line_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fragment": { + "name": "fragment", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "overlap": { + "name": "overlap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "file_search_chunk_owner_content_idx": { + "name": "file_search_chunk_owner_content_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_chunk\".\"entity_type\" = 'project'", + "concurrently": true, + "method": "gin", + "with": { + "fastupdate": "off" + } + }, + "workspace_file_search_chunk_line_idx": { + "name": "workspace_file_search_chunk_line_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "line_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_chunk_content_idx": { + "name": "workspace_file_search_chunk_content_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": true, + "method": "gin", + "with": { + "fastupdate": "off" + } + } + }, + "foreignKeys": { + "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_chunk_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_chunk", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_chunk_pk": { + "name": "workspace_file_search_chunk_pk", + "columns": ["build_id", "ordinal"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_chunk_owner_check": { + "name": "file_search_chunk_owner_check", + "value": "(\"workspace_file_search_chunk\".\"entity_type\" IS NULL AND \"workspace_file_search_chunk\".\"entity_id\" IS NULL AND \"workspace_file_search_chunk\".\"workspace_id\" IS NOT NULL) OR (\"workspace_file_search_chunk\".\"entity_type\" = 'workspace' AND \"workspace_file_search_chunk\".\"entity_id\" = \"workspace_file_search_chunk\".\"workspace_id\") OR (\"workspace_file_search_chunk\".\"entity_type\" = 'project' AND length(\"workspace_file_search_chunk\".\"entity_id\") > 0 AND \"workspace_file_search_chunk\".\"workspace_id\" IS NULL)" + }, + "workspace_file_search_chunk_content_size": { + "name": "workspace_file_search_chunk_content_size", + "value": "octet_length(\"workspace_file_search_chunk\".\"content\") <= 8192" + }, + "workspace_file_search_chunk_position": { + "name": "workspace_file_search_chunk_position", + "value": "\"workspace_file_search_chunk\".\"ordinal\" >= 0 AND \"workspace_file_search_chunk\".\"line_start\" > 0 AND \"workspace_file_search_chunk\".\"overlap\" BETWEEN 0 AND 2" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_revision": { + "name": "workspace_file_search_revision", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "handoff_expires_at": { + "name": "handoff_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "file_search_revision_owner_pending_idx": { + "name": "file_search_revision_owner_pending_idx", + "columns": [ + { + "expression": "coalesce(\"entity_type\", 'workspace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"entity_id\", \"workspace_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "file_search_revision_owner_status_idx": { + "name": "file_search_revision_owner_status_idx", + "columns": [ + { + "expression": "coalesce(\"entity_type\", 'workspace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"entity_id\", \"workspace_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_workspace_status_idx": { + "name": "workspace_file_search_revision_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_build_idx": { + "name": "workspace_file_search_revision_build_idx", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_pending_idx": { + "name": "workspace_file_search_revision_pending_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_revision_active_idx": { + "name": "workspace_file_search_revision_active_idx", + "columns": [ + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_revision\".\"status\" = 'pending' AND \"workspace_file_search_revision\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_revision_file_id_workspace_files_id_fk": { + "name": "workspace_file_search_revision_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk": { + "name": "workspace_file_search_revision_build_id_workspace_file_search_build_id_fk", + "tableFrom": "workspace_file_search_revision", + "tableTo": "workspace_file_search_build", + "columnsFrom": ["build_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "file_search_revision_owner_check": { + "name": "file_search_revision_owner_check", + "value": "(\"workspace_file_search_revision\".\"entity_type\" IS NULL AND \"workspace_file_search_revision\".\"entity_id\" IS NULL AND \"workspace_file_search_revision\".\"workspace_id\" IS NOT NULL) OR (\"workspace_file_search_revision\".\"entity_type\" = 'workspace' AND \"workspace_file_search_revision\".\"entity_id\" = \"workspace_file_search_revision\".\"workspace_id\") OR (\"workspace_file_search_revision\".\"entity_type\" = 'project' AND length(\"workspace_file_search_revision\".\"entity_id\") > 0 AND \"workspace_file_search_revision\".\"workspace_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_file_version": { + "name": "workspace_file_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "workspace_file_version_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "author_user_ids": { + "name": "author_user_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "restored_from_version": { + "name": "restored_from_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_status": { + "name": "secret_provenance_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_entries": { + "name": "secret_provenance_entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_version_file_version_unique": { + "name": "workspace_file_version_file_version_unique", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_key_unique": { + "name": "workspace_file_version_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_id_idx": { + "name": "workspace_file_version_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_version_workspace_superseded_idx": { + "name": "workspace_file_version_workspace_superseded_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "superseded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_version\".\"superseded_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_version_file_id_workspace_files_id_fk": { + "name": "workspace_file_version_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_version_workspace_id_workspace_id_fk": { + "name": "workspace_file_version_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_version", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_version_provenance_status_check": { + "name": "workspace_file_version_provenance_status_check", + "value": "\"workspace_file_version\".\"secret_provenance_status\" IS NULL OR \"workspace_file_version\".\"secret_provenance_status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "date_trunc('milliseconds', now())" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_project_id_idx": { + "name": "workspace_files_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_project_folder_name_active_unique": { + "name": "workspace_files_project_folder_name_active_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"project_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_search_owner_keyset_idx": { + "name": "workspace_files_search_owner_keyset_idx", + "columns": [ + { + "expression": "(CASE WHEN \"project_id\" IS NOT NULL THEN 'project' ELSE 'workspace' END)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"project_id\", \"workspace_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND ((\"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL) OR (\"workspace_files\".\"context\" = 'project' AND \"workspace_files\".\"project_id\" IS NOT NULL))", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_active_keyset_idx": { + "name": "workspace_files_workspace_active_keyset_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_display_name_idx": { + "name": "workspace_files_workspace_display_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "regexp_replace(regexp_replace(regexp_replace(normalize(\"original_name\", NFC), '^[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+|[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+$', '', 'g'), '[\\x01-\\x1f\\x7f]', '', 'g'), '[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]+', ' ', 'g')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_organization_id_idx": { + "name": "workspace_files_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_project_id_project_id_fk": { + "name": "workspace_files_project_id_project_id_fk", + "tableFrom": "workspace_files", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_organization_id_organization_id_fk": { + "name": "workspace_files_organization_id_organization_id_fk", + "tableFrom": "workspace_files", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_files_owner_check": { + "name": "workspace_files_owner_check", + "value": "num_nonnulls(\"workspace_files\".\"workspace_id\", \"workspace_files\".\"project_id\", \"workspace_files\".\"organization_id\") <= 1" + }, + "workspace_files_workspace_binding_check": { + "name": "workspace_files_workspace_binding_check", + "value": "\"workspace_files\".\"context\" NOT IN ('workspace', 'chat', 'mothership', 'execution', 'workspace-logos') OR \"workspace_files\".\"workspace_id\" IS NOT NULL" + }, + "workspace_files_project_binding_check": { + "name": "workspace_files_project_binding_check", + "value": "(\"workspace_files\".\"project_id\" IS NOT NULL) = (\"workspace_files\".\"context\" = 'project') AND (\"workspace_files\".\"project_id\" IS NULL OR \"workspace_files\".\"chat_id\" IS NULL)" + }, + "workspace_files_organization_binding_check": { + "name": "workspace_files_organization_binding_check", + "value": "\"workspace_files\".\"organization_id\" IS NULL OR (\"workspace_files\".\"workspace_id\" IS NULL AND \"workspace_files\".\"context\" = 'knowledge-base' AND \"workspace_files\".\"folder_id\" IS NULL AND \"workspace_files\".\"chat_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_workflow_sync": { + "name": "workspace_fork_workflow_sync", + "schema": "", + "columns": { + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workflow_id": { + "name": "source_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_deployment_version_id": { + "name": "source_deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "always", + "name": "workspace_fork_workflow_sync_sequence_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "promote_run_id": { + "name": "promote_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rollback_operation_id": { + "name": "rollback_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rolled_back_at": { + "name": "rolled_back_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_fork_workflow_sync_baseline_idx": { + "name": "workspace_fork_workflow_sync_baseline_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_fork_workflow_sync\".\"activated_at\" IS NOT NULL AND \"workspace_fork_workflow_sync\".\"rolled_back_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_run_idx": { + "name": "workspace_fork_workflow_sync_run_idx", + "columns": [ + { + "expression": "promote_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_rollback_idx": { + "name": "workspace_fork_workflow_sync_rollback_idx", + "columns": [ + { + "expression": "rollback_operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_fork_workflow_sync\".\"rollback_operation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_source_idx": { + "name": "workspace_fork_workflow_sync_source_idx", + "columns": [ + { + "expression": "source_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_target_idx": { + "name": "workspace_fork_workflow_sync_target_idx", + "columns": [ + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_workflow_sync_child_workspace_idx": { + "name": "workspace_fork_workflow_sync_child_workspace_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_workflow_sync_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_workflow_sync_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_workflow_sync_source_workflow_id_workflow_id_fk": { + "name": "workspace_fork_workflow_sync_source_workflow_id_workflow_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workflow", + "columnsFrom": ["source_workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_workflow_sync_target_workflow_id_workflow_id_fk": { + "name": "workspace_fork_workflow_sync_target_workflow_id_workflow_id_fk", + "tableFrom": "workspace_fork_workflow_sync", + "tableTo": "workflow", + "columnsFrom": ["target_workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operation_receipt": { + "name": "workspace_operation_receipt", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operation_receipt_request_unique": { + "name": "workspace_operation_receipt_request_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operation_receipt_workspace_created_idx": { + "name": "workspace_operation_receipt_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operation_receipt_workspace_id_workspace_id_fk": { + "name": "workspace_operation_receipt_workspace_id_workspace_id_fk", + "tableFrom": "workspace_operation_receipt", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_visit": { + "name": "workspace_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visited_at": { + "name": "visited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_visit_workspace_idx": { + "name": "workspace_visit_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_visit_user_id_user_id_fk": { + "name": "workspace_visit_user_id_user_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_visit_workspace_id_workspace_id_fk": { + "name": "workspace_visit_workspace_id_workspace_id_fk", + "tableFrom": "workspace_visit", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_visit_user_id_workspace_id_pk": { + "name": "workspace_visit_user_id_workspace_id_pk", + "columns": ["user_id", "workspace_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account", + "personal_token" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "project_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "organization_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool", + "workflow-test" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_file_version_source": { + "name": "workspace_file_version_source", + "schema": "public", + "values": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": { + "public.copilot_async_tool_calls_persist_seq": { + "name": "copilot_async_tool_calls_persist_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index e7018329a77..ec9b0bda3a8 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -3004,6 +3004,13 @@ "when": 1792002030922, "tag": "0429_chat_and_legacy_file_owner_indexes", "breakpoints": true + }, + { + "idx": 430, + "version": "7", + "when": 1792023339381, + "tag": "0430_tool_execution_attempts", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 2c5e13acdfc..5f38138d796 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4955,10 +4955,18 @@ export const copilotAsyncToolCalls = pgTable( executionOwnerToken: text('execution_owner_token'), executionLeaseExpiresAt: timestamp('execution_lease_expires_at', { withTimezone: true }), executionRevokedAt: timestamp('execution_revoked_at', { withTimezone: true }), + /** Sim executions started for this call, counting each re-run after its owner died. */ + executionAttempts: integer('execution_attempts').notNull().default(0), /** Assigned only after the workflow HTTP executor has reserved this execution identity. */ clientWorkflowExecutionId: text('client_workflow_execution_id'), sandboxProcesses: jsonb('sandbox_processes') - .$type>() + /** `ownerToken` names the execution attempt that started the command. */ + .$type< + Record< + string, + { sandboxId: string; sessionKey: string; settled: boolean; ownerToken?: string } + > + >() .notNull() .default({}), completedAt: timestamp('completed_at'), diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 72685874fcb..bf8304c7815 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -16,6 +16,7 @@ import { } from '../../http/client' import { describeOperation } from '../../runtime/build' import { apiCommand, type Connection } from '../../runtime/called-operations' +import { buildRequest, readArgumentSource } from '../../runtime/request' import { printProtocolResult } from './result' function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { @@ -291,6 +292,8 @@ interface DownloadOutputOptions { type DownloadOperation = | 'downloadFile' | 'downloadFileVersion' + | 'downloadProjectFileItems' + | 'exportProjectFileSnapshot' | 'readProjectFileContent' | 'readProjectFileVersionContent' @@ -347,6 +350,73 @@ async function downloadToOutput( }) } +interface ProjectFileDownloadOptions extends DownloadOutputOptions { + fileIds?: string[] + folderIds?: string[] +} + +/** Owner-specific selection uses the same bounded transfer and atomic output path as file reads. */ +export function attachProjectFileDownload(files: Command): void { + const [command, connect] = apiCommand(files, 'bulk-download', ['downloadProjectFileItems']) + command + .argument('', 'Project that owns the files') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.downloadProjectFileItems, + 'Download Project files and recursive folder contents as a zip archive' + ) + ) + .option('--file-ids ', 'File identifiers to include') + .option('--folder-ids ', 'Folder identifiers to include recursively') + .option('-o, --output-file ', 'Write the archive to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action(async (projectId: string, options: ProjectFileDownloadOptions) => { + const input = await buildRequest( + 'downloadProjectFileItems', + [projectId], + { ...options }, + null + ) + await downloadToOutput(connect, 'downloadProjectFileItems', { projectId }, options, 'owner', { + query: input.query, + }) + }) +} + +interface ProjectFileSnapshotOptions extends DownloadOutputOptions { + content: string +} + +/** Snapshot text comes from the caller's file reader, including the embedded workbench boundary. */ +export function attachProjectFileSnapshotExport(files: Command): void { + const [command, connect] = apiCommand(files, 'export', ['exportProjectFileSnapshot']) + command + .argument('', 'Project that owns the file') + .argument('', 'Markdown file whose visible snapshot to export') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.exportProjectFileSnapshot, + 'Export a visible Project Markdown snapshot with its embedded assets' + ) + ) + .requiredOption('--content ', 'Visible Markdown content or a file to read') + .option('-o, --output-file ', 'Write the export to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action(async (projectId: string, fileId: string, options: ProjectFileSnapshotOptions) => { + const source = await readArgumentSource(options.content, 'content') + await downloadToOutput( + connect, + 'exportProjectFileSnapshot', + { projectId, fileId }, + options, + 'owner', + { body: { content: source.text } } + ) + }) +} + export function attachFileGet(files: Command): void { const [command, connect] = apiCommand(files, 'get', ['downloadFile']) command diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index db2a27dff8a..edc1209833f 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -3,6 +3,8 @@ import { attachChat } from './chat' import { attachFileGet, attachFileVersionDownload, + attachProjectFileDownload, + attachProjectFileSnapshotExport, attachProjectFileSource, attachProjectFileVersionSource, } from './files-get' @@ -30,6 +32,8 @@ export function attachProtocolCommands(program: Command): void { const projectFiles = group(group(program, 'projects'), 'files') attachProjectFileUpload(projectFiles) attachProjectFileSource(projectFiles) + attachProjectFileDownload(projectFiles) + attachProjectFileSnapshotExport(projectFiles) attachProjectFileVersionSource(group(projectFiles, 'versions')) const files = group(program, 'files') attachFileUpload(files) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 1d6e415cbb3..2fa3aa993cb 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -795,6 +795,13 @@ export const CLI_CONTRACT: CliContract = { }, }, }, + copyFileItems: { + command: 'files copy', + flags: { + source: { json: true, describe: 'Source owner and selected fileIds or folderIds' }, + destination: { json: true, describe: 'Destination owner and optional folderId' }, + }, + }, renameProjectFile: { command: 'projects files rename' }, listProjectFileFolders: { command: 'projects files folders list', @@ -803,6 +810,19 @@ export const CLI_CONTRACT: CliContract = { createProjectFileFolder: { command: 'projects files folders create' }, updateProjectFileFolder: { command: 'projects files folders update' }, restoreProjectFileFolder: { command: 'projects files folders restore' }, + searchProjectFileContent: { + command: 'projects files search', + flags: { + folderPaths: FOLDER_PATHS_FLAG, + includeSubfolders: { boolean: true, negatable: true }, + }, + itemsPath: 'results', + columns: [ + { header: 'file', path: 'fileId' }, + { header: 'line', path: 'lineNumber' }, + { header: 'text' }, + ], + }, moveProjectFileItems: { command: 'projects files move', flags: { @@ -1726,6 +1746,11 @@ export const CLI_CONTRACT: CliContract = { describe: 'Unzip an archive into a new folder beside it', confirm: 'This writes every file in the archive into the workspace.', }, + unzipProjectFile: { + command: 'projects files unzip', + describe: 'Unzip an archive into a new folder beside it in the Project', + confirm: 'This writes every file in the archive into the Project.', + }, /** * Configured even though `buildGeneratedCommands` skips it: it only builds * operations whose `responseMode` is `json` (runtime/build.ts), so this @@ -1741,6 +1766,18 @@ export const CLI_CONTRACT: CliContract = { folderPaths: FOLDER_PATHS_FLAG, }, }, + downloadProjectFileItems: { + command: 'projects files bulk-download', + describe: 'Download Project files and folders as a zip archive', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + }, + }, + exportProjectFileSnapshot: { + command: 'projects files export', + describe: 'Export a visible Project Markdown snapshot with its embedded assets', + }, editFileContent: { command: 'files edit', describe: 'Apply one exact or anchor-based edit to a text file', @@ -1762,6 +1799,11 @@ export const CLI_CONTRACT: CliContract = { describe: 'Show a file’s share settings', fields: FILE_SHARE_FIELDS, }, + getProjectFileShare: { + command: 'projects files share get', + describe: 'Show a Project file’s share settings', + fields: FILE_SHARE_FIELDS, + }, // v2 folds share and unshare into one PATCH; `--is-active false` disables it, // so there is no separate unshare operation to expose. upsertFileShare: { @@ -1772,6 +1814,14 @@ export const CLI_CONTRACT: CliContract = { }, fields: FILE_SHARE_FIELDS, }, + updateProjectFileShare: { + command: 'projects files share set', + describe: 'Enable or disable sharing for a Project file', + flags: { + allowedEmails: { list: true }, + }, + fields: FILE_SHARE_FIELDS, + }, /** * None of the four folder lists paginates: the route declares no `cursor` and diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index e59ef1d56a5..61c7a43f0c0 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1820,6 +1820,67 @@ export type CompleteTableImportResponse = { data: CompleteTableImportResponseRef4 } +/** `POST /api/v2/files/copy` */ +export type CopyFileItemsQuery = Record + +export type CopyFileItemsBody = { + source: { + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + fileIds?: Array + folderIds?: Array + } + destination: { + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + folderId?: string | null + } +} + +export type CopyFileItemsResponse = { + data: { + files: Array<{ + id: string + name: string + size: number + type: string + width?: number | null + height?: number | null + uploadedBy: string + folderId: string | null + folderPath?: string | null + deletedAt: string | null + uploadedAt: string + updatedAt: string + contentUpdatedAt: string | null + revision: string + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + }> + folders: Array<{ + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + }> + } +} + /** `POST /api/v2/credentials/connections` */ export type CreateCredentialConnectionQuery = Record @@ -5139,6 +5200,19 @@ export type DownloadFileVersionQuery = { /** Non-JSON response (`binary`). */ export type DownloadFileVersionResponse = never +/** `GET /api/v2/projects/[projectId]/files/bulk-download` */ +export type DownloadProjectFileItemsParams = { + projectId: string +} + +export type DownloadProjectFileItemsQuery = { + fileIds?: string + folderIds?: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadProjectFileItemsResponse = never + /** `GET /api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]` */ export type DownloadRunFileParams = { workflowId: string @@ -5369,6 +5443,21 @@ export type ExportKnowledgeBaseQuery = { /** Non-JSON response (`binary`). */ export type ExportKnowledgeBaseResponse = never +/** `POST /api/v2/projects/[projectId]/files/[fileId]/export` */ +export type ExportProjectFileSnapshotParams = { + projectId: string + fileId: string +} + +export type ExportProjectFileSnapshotQuery = Record + +export type ExportProjectFileSnapshotBody = { + content: string +} + +/** Non-JSON response (`binary`). */ +export type ExportProjectFileSnapshotResponse = never + /** `GET /api/v2/workflows/[workflowId]/export` */ export type ExportWorkflowParams = { workflowId: string @@ -6596,6 +6685,30 @@ export type GetProjectFileMetadataResponse = { data: GetProjectFileMetadataResponseRef0 } +/** `GET /api/v2/projects/[projectId]/files/[fileId]/share` */ +export type GetProjectFileShareParams = { + projectId: string + fileId: string +} + +export type GetProjectFileShareQuery = Record + +type GetProjectFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type GetProjectFileShareResponse = { + data: GetProjectFileShareResponseRef0 | null +} + /** `GET /api/v2/projects/[projectId]/files/uploads/[uploadId]` */ export type GetProjectFileUploadParams = { projectId: string @@ -13514,6 +13627,53 @@ export type SearchKnowledgeResponse = { data: SearchKnowledgeResponseRef1 } +/** `GET /api/v2/projects/[projectId]/files/search` */ +export type SearchProjectFileContentParams = { + projectId: string +} + +export type SearchProjectFileContentQuery = { + query: string + mode?: 'exact' | 'regex' + maxResults?: number + folderPaths?: string + includeSubfolders?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type SearchProjectFileContentResponseRef0 = { + results: Array<{ + fileId: string + lineNumber: number + text: string + }> + count: number + truncated: boolean + complete: boolean + indexStatus: { + readyFiles: number + pendingFiles: number + failedFiles: number + skippedFiles: number + partialFiles: number + } +} + +export type SearchProjectFileContentResponse = { + data: SearchProjectFileContentResponseRef0 +} + /** `POST /api/v2/tables/[tableId]/rows/search` */ export type SearchTableRowsParams = { tableId: string @@ -13837,6 +13997,26 @@ export type UnzipFileResponse = { data: UnzipFileResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files/[fileId]/unzip` */ +export type UnzipProjectFileParams = { + projectId: string + fileId: string +} + +export type UnzipProjectFileQuery = Record + +type UnzipProjectFileResponseRef0 = UnzipProjectFileResponseRef1 + +type UnzipProjectFileResponseRef1 = { + folderPath: string + extractedFileCount: number + skippedFileCount: number +} + +export type UnzipProjectFileResponse = { + data: UnzipProjectFileResponseRef0 +} + /** `PATCH /api/v2/credentials/[credentialId]` */ export type UpdateCredentialParams = { credentialId: string @@ -14522,6 +14702,37 @@ export type UpdateProjectFileFolderResponse = { } } +/** `PATCH /api/v2/projects/[projectId]/files/[fileId]/share` */ +export type UpdateProjectFileShareParams = { + projectId: string + fileId: string +} + +export type UpdateProjectFileShareQuery = Record + +export type UpdateProjectFileShareBody = { + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +type UpdateProjectFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type UpdateProjectFileShareResponse = { + data: UpdateProjectFileShareResponseRef0 +} + /** `PATCH /api/v2/tables/[tableId]/rows` */ export type UpdateRowsByFilterParams = { tableId: string @@ -16148,6 +16359,26 @@ export const V2_OPERATIONS = { }, }, }, + copyFileItems: { + method: 'POST', + path: '/api/v2/files/copy', + pathParams: [] as const, + responseMode: 'json', + summary: 'Copy File Items', + workspaceKeyUnsupported: true, + body: { + source: { + kind: 'object', + required: true, + describe: 'Selection to read under the source owner.', + }, + destination: { + kind: 'object', + required: true, + describe: 'Destination requiring file write access.', + }, + }, + }, createCredentialConnection: { method: 'POST', path: '/api/v2/credentials/connections', @@ -17787,6 +18018,26 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, }, }, + downloadProjectFileItems: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/bulk-download', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'binary', + summary: 'Download Project File Items', + workspaceKeyUnsupported: true, + query: { + fileIds: { + kind: 'string', + describe: 'File identifiers to include, comma-separated. At most 100 entries.', + }, + folderIds: { + kind: 'string', + describe: + 'Folder identifiers to include recursively, comma-separated. The resolved selection allows at most 100 files.', + }, + }, + }, downloadRunFile: { method: 'GET', path: '/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]', @@ -17969,6 +18220,26 @@ export const V2_OPERATIONS = { }, }, }, + exportProjectFileSnapshot: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/export', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'binary', + summary: 'Export Project File Snapshot', + workspaceKeyUnsupported: true, + body: { + content: { + kind: 'string', + required: true, + describe: + 'Visible Markdown snapshot to export. This does not replace the stored file or create a version.', + }, + }, + }, exportWorkflow: { method: 'GET', path: '/api/v2/workflows/[workflowId]/export', @@ -18500,6 +18771,18 @@ export const V2_OPERATIONS = { summary: 'Get Project File Metadata', workspaceKeyUnsupported: true, }, + getProjectFileShare: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project File Share', + workspaceKeyUnsupported: true, + }, getProjectFileUpload: { method: 'GET', path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', @@ -23185,6 +23468,53 @@ export const V2_OPERATIONS = { }, }, }, + searchProjectFileContent: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/search', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Search Project File Content', + workspaceKeyUnsupported: true, + query: { + query: { + kind: 'string', + required: true, + describe: 'Regular expression, or exact text when `mode` is `exact`.', + }, + mode: { + kind: 'enum', + values: ['exact', 'regex'] as const, + default: 'regex', + describe: 'How `query` is read.', + }, + maxResults: { kind: 'integer', default: 50, describe: 'Maximum matching lines to return.' }, + folderPaths: { + kind: 'string', + describe: + 'Comma-separated folder paths within the Project. Omit to search the entire Project; index coverage applies to the selected folders.', + }, + includeSubfolders: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + describe: + 'Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.', + }, + }, + }, searchTableRows: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/search', @@ -23345,6 +23675,18 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the archive.' }, }, }, + unzipProjectFile: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/unzip', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Unzip Project File', + workspaceKeyUnsupported: true, + }, updateCredential: { method: 'PATCH', path: '/api/v2/credentials/[credentialId]', @@ -23838,6 +24180,42 @@ export const V2_OPERATIONS = { sortOrder: { kind: 'integer', describe: 'New manual position; omit to leave unchanged.' }, }, }, + updateProjectFileShare: { + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Update Project File Share', + workspaceKeyUnsupported: true, + body: { + isActive: { + kind: 'boolean', + required: true, + describe: + 'Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use.', + }, + authType: { + kind: 'enum', + values: ['public', 'password', 'email', 'sso'] as const, + describe: + 'How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.', + }, + password: { + kind: 'string', + describe: + 'Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected.', + }, + allowedEmails: { + kind: 'array', + describe: + 'Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.', + }, + }, + }, updateRowsByFilter: { method: 'PATCH', path: '/api/v2/tables/[tableId]/rows', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 9a56cba9541..63146764eb6 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -419,17 +419,21 @@ describe('destructive operations are gated', () => { */ const NON_DESTRUCTIVE = new Set([ 'completeProjectFileUpload', + 'copyFileItems', 'createProjectFile', 'createProjectFileFolder', 'createProjectFileUpload', + 'exportProjectFileSnapshot', 'getProjectFileUploadPartUrls', 'moveProjectFileItems', 'renameProjectFile', 'restoreProjectFile', 'restoreProjectFileFolder', 'revertProjectFileVersion', + 'unzipProjectFile', 'updateProjectFileContent', 'updateProjectFileFolder', + 'updateProjectFileShare', /** These amend access or request history without discarding a resource. */ 'cancelOrganizationAccessRequest', 'cancelWorkspaceAccessRequest', diff --git a/packages/testing/src/mocks/file-read-receipt.mock.ts b/packages/testing/src/mocks/file-read-receipt.mock.ts new file mode 100644 index 00000000000..5eaf89af15f --- /dev/null +++ b/packages/testing/src/mocks/file-read-receipt.mock.ts @@ -0,0 +1,16 @@ +import { vi } from 'vitest' + +const fileReadReceiptMockFns = { + mockCreateFileReadReceipt: vi.fn(() => ({ + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + files: [], + })), + mockAssertFileReadReceiptCurrent: vi.fn(async () => []), + mockRecheckFileReadReceipt: vi.fn(async () => ({ status: 'exact' as const, entries: [] })), +} + +export const fileReadReceiptMock = { + assertFileReadReceiptCurrent: fileReadReceiptMockFns.mockAssertFileReadReceiptCurrent, + createFileReadReceipt: fileReadReceiptMockFns.mockCreateFileReadReceipt, + recheckFileReadReceipt: fileReadReceiptMockFns.mockRecheckFileReadReceipt, +} diff --git a/packages/testing/src/mocks/mothership-async-runs.mock.ts b/packages/testing/src/mocks/mothership-async-runs.mock.ts index 94e62736d88..a44ea88fd5c 100644 --- a/packages/testing/src/mocks/mothership-async-runs.mock.ts +++ b/packages/testing/src/mocks/mothership-async-runs.mock.ts @@ -40,6 +40,9 @@ export const mothershipAsyncRunsMockFns = { async (_toolCallId: string): Promise => null ), mockRevokeExpiredSimToolExecutions: vi.fn(), + /** No Sim tool still running by default, so a takeover may proceed. */ + mockHasLiveSimToolExecution: vi.fn(async (_runId: string): Promise => false), + mockRevokeExhaustedSimToolExecutions: vi.fn(async (_runId: string): Promise => []), mockSettleSimToolExecution: vi.fn(), mockSettleClientWorkflowToolExecution: vi.fn(), mockRecordSimSandboxProcess: vi.fn(), @@ -103,6 +106,8 @@ export const mothershipAsyncRunsMock = { getChatViewDesktopLeaseRemainingMs: mothershipAsyncRunsMockFns.mockGetChatViewDesktopLeaseRemainingMs, revokeExpiredSimToolExecutions: mothershipAsyncRunsMockFns.mockRevokeExpiredSimToolExecutions, + hasLiveSimToolExecution: mothershipAsyncRunsMockFns.mockHasLiveSimToolExecution, + revokeExhaustedSimToolExecutions: mothershipAsyncRunsMockFns.mockRevokeExhaustedSimToolExecutions, settleSimToolExecution: mothershipAsyncRunsMockFns.mockSettleSimToolExecution, settleClientWorkflowToolExecution: mothershipAsyncRunsMockFns.mockSettleClientWorkflowToolExecution, diff --git a/packages/testing/src/mocks/public-shares.mock.ts b/packages/testing/src/mocks/public-shares.mock.ts index 471efb888b5..98a3980e1f7 100644 --- a/packages/testing/src/mocks/public-shares.mock.ts +++ b/packages/testing/src/mocks/public-shares.mock.ts @@ -26,7 +26,7 @@ export class MockShareValidationError extends Error { * ```ts * import { publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' * - * publicSharesMockFns.mockResolveActiveShareByToken.mockResolvedValue(share) + * publicSharesMockFns.mockGetShareForResource.mockResolvedValue(share) * ``` */ export const publicSharesMockFns = { @@ -42,7 +42,6 @@ export const publicSharesMockFns = { async (..._args: unknown[]): Promise> => new Map() ), mockUpsertFileShare: vi.fn(), - mockResolveActiveShareByToken: vi.fn(), } /** @@ -62,5 +61,4 @@ export const publicSharesMock = { getWorkspaceSharesForResources: publicSharesMockFns.mockGetWorkspaceSharesForResources, getWorkspaceShares: publicSharesMockFns.mockGetWorkspaceShares, upsertFileShare: publicSharesMockFns.mockUpsertFileShare, - resolveActiveShareByToken: publicSharesMockFns.mockResolveActiveShareByToken, } diff --git a/packages/testing/src/mocks/schema-tables.generated.ts b/packages/testing/src/mocks/schema-tables.generated.ts index dbc695348ec..869c97f393b 100644 --- a/packages/testing/src/mocks/schema-tables.generated.ts +++ b/packages/testing/src/mocks/schema-tables.generated.ts @@ -1206,6 +1206,7 @@ export const GENERATED_SCHEMA_TABLES = { 'executionOwnerToken', 'executionLeaseExpiresAt', 'executionRevokedAt', + 'executionAttempts', 'clientWorkflowExecutionId', 'sandboxProcesses', 'completedAt', diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index 738a21d39ea..1f3e57a79e0 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1582,7 +1582,6 @@ "apps/sim/lib/mothership/chat/payload.test.ts": 1, "apps/sim/lib/mothership/chat/persisted-message.ts": 4, "apps/sim/lib/mothership/chat/post.test.ts": 2, - "apps/sim/lib/mothership/chat/post.ts": 1, "apps/sim/lib/mothership/chat/process-contents.test.ts": 17, "apps/sim/lib/mothership/chat/stream-liveness.ts": 1, "apps/sim/lib/mothership/chat/title-context.test.ts": 4, diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json index ca3bcffbbc6..ecc1d0dc95d 100644 --- a/scripts/check-source-conventions.baseline.json +++ b/scripts/check-source-conventions.baseline.json @@ -283,7 +283,6 @@ "apps/sim/lib/uploads/contexts/workspace/track-chat-upload.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager-errors.test.ts": 1, "apps/sim/lib/uploads/contexts/workspace/file-manager.test.ts": 1, - "apps/sim/lib/uploads/core/storage-client.ts": 1, "apps/sim/lib/webhooks/providers/table.ts": 1, "apps/sim/lib/webhooks/providers/whatsapp.test.ts": 1, "apps/sim/lib/workflows/comparison/normalize.test.ts": 1, diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index ac3c533a2a3..032ed628d74 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1084,7 +1084,6 @@ "apps/sim/hooks/queries/providers.ts#providerKeys", "apps/sim/hooks/queries/providers.ts#providerModelsQueryOptions", "apps/sim/hooks/queries/public-shares.ts#FILE_SHARE_STALE_TIME", - "apps/sim/hooks/queries/public-shares.ts#shareKeys", "apps/sim/hooks/queries/resume-execution.ts#PausedExecutionSummary", "apps/sim/hooks/queries/resume-execution.ts#RESUME_EXECUTION_DETAIL_STALE_TIME", "apps/sim/hooks/queries/resume-execution.ts#ResumeLinks", @@ -1820,7 +1819,6 @@ "apps/sim/lib/api/contracts/primitives.ts#retentionOverrideSchema", "apps/sim/lib/api/contracts/providers.ts#openRouterModelInfoSchema", "apps/sim/lib/api/contracts/public-shares.ts#AuthenticatePublicFileBody", - "apps/sim/lib/api/contracts/public-shares.ts#GetFileShareResponse", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileMetadata", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileSSOBody", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileSSOResponse", @@ -3254,19 +3252,15 @@ "apps/sim/lib/api/contracts/v2/files.ts#v2FileMetadataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileReferenceParamsSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileScopeSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileSearchResultsSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileShareSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadParamsSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadWorkspaceQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2GetFileMetadataQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2ListFileFoldersQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2MoveFileItemsResultSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2NullableFileShareSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2UnzipFileBodySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2UnzipFileDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2WrittenFileSchema", "apps/sim/lib/api/contracts/v2/knowledge.ts#MAX_V2_BULK_KNOWLEDGE_DOCUMENTS", "apps/sim/lib/api/contracts/v2/knowledge.ts#MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS", @@ -3928,7 +3922,6 @@ "apps/sim/lib/api/contracts/workflows.ts#workflowVariableReadSchema", "apps/sim/lib/api/contracts/workflows.ts#workflowVariableWriteSchema", "apps/sim/lib/api/contracts/workflows.ts#workflowVariablesBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#downloadWorkspaceFileItemsQuerySchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderParamsSchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderScopeSchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFoldersParamsSchema", @@ -3937,7 +3930,6 @@ "apps/sim/lib/api/contracts/workspace-file-table.ts#workspaceCsvPreviewParamsSchema", "apps/sim/lib/api/contracts/workspace-files.ts#ExportWorkspaceFileSnapshotBody", "apps/sim/lib/api/contracts/workspace-files.ts#UpdateWorkspaceFileDimensionsBody", - "apps/sim/lib/api/contracts/workspace-files.ts#extractWorkspaceFileResponseSchema", "apps/sim/lib/api/contracts/workspace-files.ts#listWorkspaceFilesQuerySchema", "apps/sim/lib/api/contracts/workspace-files.ts#renameWorkspaceFileErrorSchema", "apps/sim/lib/api/contracts/workspace-files.ts#updateWorkspaceFileDimensionsBodySchema", @@ -5047,7 +5039,6 @@ "apps/sim/lib/tokenization/streaming.ts#processStreamingBlockLog", "apps/sim/lib/tokenization/types.ts#TokenizationInput", "apps/sim/lib/uploads/archive.ts#MAX_ARCHIVE_ENTRIES", - "apps/sim/lib/uploads/archive.ts#MAX_ARCHIVE_TOTAL_BYTES", "apps/sim/lib/uploads/client/upload-session.ts#UploadSessionTransportError", "apps/sim/lib/uploads/contexts/copilot/file-manager.ts#isSupportedFileType", "apps/sim/lib/uploads/contexts/copilot/index.ts#CopilotStoredFile", @@ -5060,7 +5051,6 @@ "apps/sim/lib/uploads/contexts/workspace/file-manager.ts#matchesWorkspaceFilePattern", "apps/sim/lib/uploads/contexts/workspace/file-secret-provenance.ts#WorkspaceFileSecretProvenanceEnvelope", "apps/sim/lib/uploads/index.ts#getStorageConfig", - "apps/sim/lib/uploads/server/heic.ts#MAX_TRANSCODE_INPUT_BYTES", "apps/sim/lib/uploads/shared/types.ts#WORKSPACE_SCOPED_CONTEXTS", "apps/sim/lib/uploads/upload-session/application.ts#UploadSessionCreateResult", "apps/sim/lib/uploads/upload-session/application.ts#UploadSessionTransfer",