diff --git a/.changeset/README.md b/.changeset/README.md index 3eccc0bd1..7c9773cf4 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -6,7 +6,14 @@ This repo uses Changesets to drive releases for the published `executor` CLI. Only `executor` is managed directly by Changesets. -Release PRs should only version the published CLI package instead of the rest of the workspace. +Release PRs should only mention the published CLI package directly. Changesets +will still version the fixed release group and dependent public packages as +needed, and will update each affected package's `CHANGELOG.md`. + +Write the changeset body as the package changelog entry you want to appear in +the Version Packages PR and in the affected package changelogs. Keep broader +user-facing launch notes in `apps/cli/release-notes/next.md`; those are used for +the GitHub Release body. ## Beta releases diff --git a/.changeset/config.json b/.changeset/config.json index 5a6cfb345..1853f375e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", - "changelog": false, + "changelog": ["@changesets/changelog-github", { "repo": "RhysSullivan/executor" }], "commit": false, "fixed": [ [ @@ -11,7 +11,6 @@ "@executor-js/config", "executor", "@executor-js/plugin-file-secrets", - "@executor-js/plugin-google-discovery", "@executor-js/plugin-graphql", "@executor-js/plugin-keychain", "@executor-js/plugin-mcp", diff --git a/.changeset/desktop-publish-fixes.md b/.changeset/desktop-publish-fixes.md new file mode 100644 index 000000000..f30a51a5a --- /dev/null +++ b/.changeset/desktop-publish-fixes.md @@ -0,0 +1,8 @@ +--- +"executor": patch +--- + +Desktop packaging follow-ups from the v1.5.2 release run: + +- Fixed the Intel mac desktop build failing in CI (the cross-target dependency install was being glob-expanded by the shell). +- Fixed the first-launch data migration on Windows: renaming the previous database file could hit a transient `EBUSY` while the just-closed SQLite handle was released, so the move now retries briefly instead of failing startup. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 879d1696c..55076666c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,3 +98,20 @@ jobs: - name: Build Electron main/preload/renderer run: bunx --bun electron-vite build working-directory: apps/desktop + + selfhost-docker-smoke: + name: Self-host Docker image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build self-host image + uses: docker/build-push-action@v6 + with: + context: . + file: apps/host-selfhost/Dockerfile + push: false + tags: executor-selfhost:ci diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 93860f782..cb9cf0544 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -110,7 +110,6 @@ jobs: './packages/kernel/core' './packages/kernel/runtime-quickjs' './packages/plugins/file-secrets' - './packages/plugins/google-discovery' './packages/plugins/graphql' './packages/plugins/keychain' './packages/plugins/mcp' diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index eea1eccb0..f77dce784 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -29,22 +29,29 @@ jobs: fail-fast: false matrix: include: + # smoke: run the compiled-sidecar smoke test on legs whose target + # matches the runner (the mac x64 leg cross-compiles on an arm64 + # runner, so its binary can't be executed natively there). - os: macos-latest arch: arm64 platform: mac bun-target: bun-darwin-arm64 + smoke: true - os: macos-latest arch: x64 platform: mac bun-target: bun-darwin-x64 + smoke: false - os: ubuntu-latest arch: x64 platform: linux bun-target: bun-linux-x64 + smoke: true - os: windows-latest arch: x64 platform: win bun-target: bun-windows-x64 + smoke: true runs-on: ${{ matrix.os }} @@ -90,6 +97,15 @@ jobs: run: bun ./scripts/build-sidecar.ts working-directory: apps/desktop + # Gate the release on the compiled binary actually booting. v1.5.0/.1 + # shipped sidecars that died on launch (missing libsql native binding) — + # a regression dev mode can't catch because `bun run` resolves + # node_modules that `bun build --compile` does not bundle. + - name: Smoke test compiled sidecar + if: matrix.smoke + run: bun run test:smoke + working-directory: apps/desktop + - name: Build Electron main/preload/renderer run: bunx --bun electron-vite build working-directory: apps/desktop @@ -124,6 +140,16 @@ jobs: run: bunx --bun electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never --config electron-builder.config.ts working-directory: apps/desktop + # The two mac legs each emit a latest-mac.yml listing only their own + # arch. Rename per-arch here; the release job merges them back into the + # single latest-mac.yml electron-updater clients fetch. Without this, + # merge-multiple in the release job lets one arch clobber the other and + # every Mac gets pointed at whichever leg uploaded last. + - name: Rename mac update manifest per arch + if: matrix.platform == 'mac' + shell: bash + run: mv apps/desktop/dist/latest-mac.yml "apps/desktop/dist/latest-mac-${{ matrix.arch }}.yml" + - name: Upload artifacts uses: actions/upload-artifact@v4 with: @@ -166,6 +192,15 @@ jobs: path: artifacts merge-multiple: true + - name: Merge mac update manifests + run: | + set -euo pipefail + bun scripts/merge-latest-mac-yml.ts \ + artifacts/latest-mac-x64.yml \ + artifacts/latest-mac-arm64.yml \ + artifacts/latest-mac.yml + rm artifacts/latest-mac-x64.yml artifacts/latest-mac-arm64.yml + - name: Upload to GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-executor-package.yml b/.github/workflows/publish-executor-package.yml index 2424e4215..113ed9411 100644 --- a/.github/workflows/publish-executor-package.yml +++ b/.github/workflows/publish-executor-package.yml @@ -82,3 +82,8 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: gh workflow run publish-desktop.yml -f tag="$RELEASE_TAG" + + - name: Trigger self-host Docker publish + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run publish-selfhost-docker.yml -f tag="$RELEASE_TAG" diff --git a/.github/workflows/publish-selfhost-docker.yml b/.github/workflows/publish-selfhost-docker.yml new file mode 100644 index 000000000..ac54fb799 --- /dev/null +++ b/.github/workflows/publish-selfhost-docker.yml @@ -0,0 +1,107 @@ +name: Publish Self-host Docker Image +run-name: "${{ format('publish self-host docker {0}', inputs.tag) }}" + +on: + workflow_dispatch: + inputs: + tag: + description: Git tag to publish (e.g. v1.5.0) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-selfhost-docker-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Validate release tag + env: + RAW_RELEASE_TAG: ${{ inputs.tag }} + run: bun run scripts/validate-release-ref.ts --tag-env RAW_RELEASE_TAG --write-env RELEASE_TAG + + - name: Checkout release tag + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT || github.token }} + run: | + auth_remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git fetch --force --tags "$auth_remote" "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG" + git checkout --detach "$RELEASE_TAG" + + - name: Resolve image metadata + id: image + shell: bash + run: | + set -euo pipefail + + version="${RELEASE_TAG#v}" + owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" + image="ghcr.io/${owner}/executor-selfhost" + revision="$(git rev-parse HEAD)" + + if [[ "$version" == *-* ]]; then + channel="beta" + else + channel="latest" + fi + + { + echo "image=$image" + echo "version=$version" + echo "channel=$channel" + echo "tags<> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push self-host image + uses: docker/build-push-action@v6 + with: + context: . + file: apps/host-selfhost/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.image.outputs.tags }} + labels: ${{ steps.image.outputs.labels }} diff --git a/.gitignore b/.gitignore index b9b3aed73..aab323e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,11 @@ apps/desktop/resources/ apps/cloud/.dev-db/ apps/cloud/.e2e-db/ +# e2e suite: generated run artifacts + throwaway target state +e2e/runs/ +apps/cloud/.e2e-stub-db/ +apps/host-selfhost/.e2e-data/ + # playwright e2e artifacts test-results/ playwright-report/ @@ -100,3 +105,9 @@ LEARNINGS.md # Pi coding agent .pi/ + +# Throwaway UX prototype (not part of the app) +ux-demo/ + +# testkit run outputs (superseded by e2e/runs, also generated) +testkit/runs/ diff --git a/.gitmodules b/.gitmodules index 2ac0d6064..2d0fb0c20 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "integrationsdotsh"] path = integrationsdotsh url = https://github.com/RhysSullivan/integrationsdotsh.git +[submodule "vendor/emulate"] + path = vendor/emulate + url = https://github.com/UsefulSoftwareCo/emulate.git +[submodule "vendor/mcporter"] + path = vendor/mcporter + url = https://github.com/UsefulSoftwareCo/mcporter.git diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 5ae9d84b2..79fa51c00 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,6 +5,9 @@ ".reference", ".turbo", "dist", + "vendor", + "testkit", + "e2e/runs", "integrationsdotsh", "node_modules", "packages/core/fumadb", diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index f8897c394..2cd7c056c 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -68,11 +68,30 @@ }, }, { - // Playwright e2e specs drive a real browser: they stringify browser-side - // errors and use console/promise APIs that the Effect-domain rules forbid. - "files": ["apps/cloud/e2e/**/*.{ts,tsx}"], + // The e2e harness internals are adapter boundaries over promise-native + // drivers (Playwright, PTY, mcporter, raw HTTP) plus build tooling (the + // run viewer); they normalize untyped failures into the recorded + // transcript. Scenario code (e2e/scenarios, e2e/cloud, …) stays strict. + "files": [ + "e2e/src/**/*.{ts,tsx}", + "e2e/setup/**/*.{ts,tsx}", + "e2e/targets/**/*.{ts,tsx}", + "e2e/scripts/**/*.{ts,tsx}", + "e2e/viewer/**/*.{ts,tsx}", + ], "rules": { + // The viewer is standalone tooling — it deliberately doesn't pull the + // product design system or Effect in. + "react/forbid-elements": "off", + "executor/no-switch-statement": "off", + "executor/no-effect-escape-hatch": "off", + "executor/no-error-constructor": "off", + "executor/no-instanceof-error": "off", + "executor/no-instanceof-tagged-error": "off", + "executor/no-json-parse": "off", + "executor/no-manual-tag-check": "off", "executor/no-promise-catch": "off", + "executor/no-redundant-primitive-cast": "off", "executor/no-try-catch-or-throw": "off", "executor/no-unknown-error-message": "off", }, @@ -127,6 +146,10 @@ ".turbo/", ".worktrees/", "dist/", + "vendor/", + "emulators/", + "testkit/", + "e2e/runs/", "integrationsdotsh/", "node_modules/", "packages/core/fumadb/", diff --git a/.skills/cli-release/SKILL.md b/.skills/cli-release/SKILL.md index 7580aa0ae..11fb4234c 100644 --- a/.skills/cli-release/SKILL.md +++ b/.skills/cli-release/SKILL.md @@ -33,81 +33,59 @@ Does **not** ship in the CLI: - Only the Changesets-generated `Version Packages` PR should move `apps/cli/package.json`. If a normal PR directly changes that version, merging it to `main` can make `.github/workflows/release.yml` tag the commit and dispatch `publish-executor-package.yml`, causing an immediate CLI publish. - `@executor-js/*` library packages have their own publish path. -## Release notes: single source of truth, curated, not auto-generated +## Release notes: standard Changesets flow — the changeset body IS the changelog -The owner doesn't want GitHub's auto-generated "PR title by @user" list. Release notes live at `apps/cli/release-notes/` and `apps/cli/src/release.ts` prefers them over `--generate-notes`. - -**`apps/cli/release-notes/next.md` is the canonical user-facing changelog.** Per-package workspace `CHANGELOG.md` files are one-line stubs required by `changesets/action@v1` (the GitHub Action wrapping the CLI in `release.yml`) — it reads each bumped package's `CHANGELOG.md` to build the Version Packages PR description and crashes with `ENOENT` if any is missing. The stubs satisfy that read; don't put release content in them. +As of v1.5.0 this repo uses the canonical Changesets pipeline. The old +`apps/cli/release-notes/next.md` rolling file is gone — do not recreate it. ### How it's wired -`apps/cli/src/release.ts` reads `apps/cli/release-notes/next.md` and uses -its contents as the GitHub Release body. If the file is missing or empty, -falls back to `gh release create --generate-notes`. There's no -per-version archive in the repo — historical release bodies live on -GitHub Releases (durable, indexed, linkable). - -### Writing conventions - -Structure release-notes files as: - -``` -## Highlights -### # e.g. "Per-user OAuth for OpenAPI and MCP sources" - bullets of concrete user value - -## New presets # optional - -## Performance # optional - -## Fixes - -## Breaking changes -### - before / after code blocks for migrations -``` - -Lead with **user-visible stories**, not commit subjects. Group related commits into one story (e.g. 6 commits about Connections → one "Per-user OAuth" section). Include before/after CLI snippets for any breaking change. Keep bullets single-line. - -### Attribution - -External contributor bullets end with `Thanks @ (#PR)`: - -```markdown -- OAuth2 client-credentials flow end-to-end. Thanks @octocat (#456) -``` - -Do not `Thanks` maintainers, bots, or the repo owner — the lint script rejects `@claude`, `@anthropic`, `@github-actions`, `@dependabot`, `@renovate`, `@rhyssullivan`, `@rhys-sullivan`. Run `bun run lint:release-notes` before pushing notes. - -### When drafting from `git log` - -- Look at `git diff v..HEAD -- README.md` first — it's the best single view of user-facing changes. -- Read commit messages in bulk (`git log --oneline v..HEAD -- apps/cli apps/local packages`), then bucket by theme before writing prose. -- Don't list every commit. Merge PRs and refactor-chain commits into one line. - -### Pairing with changesets - -- A `.changeset/*.md` describes the version bump (semver level + a short summary for the Version Packages PR description). It is **not** the user-facing changelog. -- If your PR adds a `.changeset/*.md` for the `executor` package, also edit `apps/cli/release-notes/next.md` for the user-facing story. They have different audiences. -- The `.changeset/*.md` body can be a one-liner pointing at the release-notes section it expands; users read the GitHub release body, not the changeset. -- Frontmatter is `"executor": patch` (or `minor`/`major` if owner says so). - -### Starting a new release cycle - -There's no post-release rename step. When you start work on the next -release, replace the existing `next.md` content with new entries — the -previous cycle's content is already preserved on the matching `vX.Y.Z` -GitHub Release page, so overwriting locally is safe. If `next.md` content -looks stale (i.e. mentions features already shipped), that's the signal -to clear it. +- Every user-visible PR adds a `.changeset/*.md`; its **body** is the + user-facing changelog entry. +- `changeset version` (run by `changesets/action@v1` when building the + Version Packages PR) compiles changeset bodies into each bumped + package's `CHANGELOG.md` using `@changesets/changelog-github` + (configured in `.changeset/config.json`), which prefixes each entry + with the PR link and credits the author automatically. +- `apps/cli/src/release.ts` (`changelogSectionForVersion`) extracts the + released version's `## ` section from `apps/cli/CHANGELOG.md` + and uses it as the GitHub Release body. Missing section → falls back to + `--generate-notes`. +- Per-package `CHANGELOG.md` seed files are still required for every + workspace package (`bun run lint:changelog-stubs --fix` creates them); + `changesets/action@v1` crashes with `ENOENT` on missing files. +- `@changesets/changelog-github` needs `GITHUB_TOKEN` during + `changeset version`. CI provides it; locally: + `GITHUB_TOKEN=$(gh auth token) bun run changeset:version`. + +### Writing changeset bodies + +- Lead with user-visible behavior, not implementation. One sentence for a + typical fix; a short paragraph for a feature. +- Big releases: a changeset body can be a full markdown section — use + **bold sub-headings** + bullets, never `#`/`##` headings (they end up + nested inside a changelog list item). +- Breaking changes: include the before/after surface in the body. +- Don't duplicate content across changesets — every changeset in the + release lands in the same version section. +- Attribution is automatic via changelog-github; don't hand-write + `Thanks @...` lines. + +### When drafting a release-spanning changeset from `git log` + +- Look at `git diff v..HEAD -- README.md` first — best single view of user-facing changes. +- Read commits in bulk (`git log --oneline v..HEAD -- apps/cli apps/local packages`), bucket by theme, then write prose. +- Merged PRs without changesets still ship in the release — their content + ships regardless; only the changelog text is driven by changesets. If + something important landed without a changeset, fold its story into a + release-summary changeset. ## Beta release flow ``` git checkout -b rs/beta-v-start bun run release:beta:start # creates .changeset/pre.json -# write .changeset/executor--beta.md (patch frontmatter by default) -# write apps/cli/release-notes/next.md (curated notes) +# write .changeset/executor--beta.md (frontmatter + user-facing body) git add ... && git commit # ONLY when owner says commit git push -u origin rs/beta-v-start # Open PR -> merge -> release.yml opens "Version Packages (beta)" PR -> merge to publish @@ -135,7 +113,7 @@ Identical to beta except skip `release:beta:start`/`stop`. Changesets produce a ``` bun run changeset # interactive; or write .changeset/*.md directly -bun run lint:release-notes # validate apps/cli/release-notes/next.md +bun run lint:changelog-stubs --fix # seed missing per-package CHANGELOG.md files bun run release:beta:start # enter prerelease bun run release:beta:stop # exit prerelease bun run release:publish:dry-run # build full CLI payload without publishing diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a7c31901..2c98f0527 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,5 @@ "typescript.tsdk": "./node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "typescript.native-preview.tsdk": "node_modules/@typescript/native-preview", - "typescript.experimental.useTsgo": true, - "js/ts.experimental.useTsgo": true + "typescript.experimental.useTsgo": false } diff --git a/RELEASING.md b/RELEASING.md index e166f36a0..7285fa719 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,8 +1,9 @@ # Releasing -This repo uses Changesets for version orchestration and two publish paths: -the CLI (`executor` npm package plus its platform packages) and the -`@executor-js/*` library packages (`core`, `sdk`, and the public plugins). +This repo uses Changesets for version orchestration and three publish paths: +the CLI (`executor` npm package plus its platform packages), the +`@executor-js/*` library packages (`core`, `sdk`, and the public plugins), and +the self-host Docker image. ## Normal release flow @@ -21,6 +22,12 @@ the CLI (`executor` npm package plus its platform packages) and the - performs a full dry-run release build before publish - publishes the CLI npm package under the correct dist-tag - creates or updates the GitHub release with build artifacts + - dispatches `.github/workflows/publish-desktop.yml` + - dispatches `.github/workflows/publish-selfhost-docker.yml` +6. The self-host Docker workflow publishes `ghcr.io/rhyssullivan/executor-selfhost` + for `linux/amd64` and `linux/arm64`: + - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest` + - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta` ## Beta releases @@ -52,81 +59,50 @@ To pack the `@executor-js/*` library packages without publishing: - `bun run release:publish:packages:dry-run` -## Release notes - -User-facing release notes live at `apps/cli/release-notes/next.md` — -one rolling file. **This is the single source of truth users see.** Edit -it whenever you ship a user-visible change. - -`apps/cli/src/release.ts` reads `next.md` and uses its contents as the -GitHub Release body. If the file is missing or empty it falls back to -`gh release create --generate-notes` (auto-generated from PR titles). - -There's no per-version archive in the repo — historical release bodies -live on GitHub Releases (durable, indexed, linkable). When you start a -new release cycle, replace the existing `next.md` content with your new -entries; the previous cycle's content is already preserved on the -matching `vX.Y.Z` release page. - -### Authoring rules - -Use this section structure (mirrors what's already in `next.md`): - -```markdown -## Highlights +To validate the self-host Dockerfile locally without publishing: -### +- `docker build -f apps/host-selfhost/Dockerfile -t executor-selfhost:local .` -bullets of concrete user value - -## Fixes - -## Breaking changes - -### - -before / after code blocks for migrations -``` - -Lead with **user-visible stories**, not commit subjects. Group related -commits into one story. Keep bullets single-line so diffs and dedupe -tooling stay simple. - -### Attribution +## Release notes -For external contributors, end the bullet with `Thanks @` and the -PR ref: +Release notes follow the standard Changesets flow: **the changeset body +IS the changelog entry.** Write the user-facing summary in the +`.changeset/*.md` you add with your PR; `changeset version` compiles +every changeset into the bumped packages' `CHANGELOG.md` files (via +`@changesets/changelog-github`, which links the PR and credits the +author), and `apps/cli/src/release.ts` uses the released version's +section of `apps/cli/CHANGELOG.md` as the GitHub Release body. If the +section is missing it falls back to `gh release create +--generate-notes`. -```markdown -- OAuth2 client-credentials flow end-to-end. Thanks @octocat (#456) -``` +There is no separate release-notes file to remember to update — if your +change deserves a mention, its changeset body is the mention. -Don't `Thanks` maintainers, bots, or the repo owner. The lint script -(`bun run lint:release-notes`) rejects `Thanks @claude`, -`Thanks @rhyssullivan`, `Thanks @github-actions`, etc. — the full list -is in `scripts/check-release-notes.ts`. Run it before pushing release -notes. +### Authoring rules -### When you ship a change +Write changeset bodies for users, not for the diff: -If your PR adds a `.changeset/*.md` for the `executor` package, also -edit `apps/cli/release-notes/next.md`. The changeset describes the -version bump; the release-notes file describes the user impact. They're -different audiences and shouldn't be conflated. +- Lead with the user-visible behavior, not the implementation. +- A typical fix is one sentence. A feature can be a short paragraph. +- For a large release, a changeset body can be a full markdown section + (bold sub-headings + bullets). Avoid `#`/`##` headings inside bodies — + they end up nested inside a changelog list item. +- For breaking changes, include the before/after surface in the body. -The `.changeset/*.md` body is fine as a one-liner pointing at the -release-notes section it expands. +Contributor attribution is automatic: `@changesets/changelog-github` +prefixes each entry with the PR link and the author's handle. ## Notes - Changesets owns the published CLI version via `apps/cli/package.json`. -- Only `apps/cli/package.json` should change during release versioning; the rest of the workspace is not version-synced for release PRs. -- Changesets changelog file generation is disabled (`changelog: false` - in `.changeset/config.json`), but per-package `CHANGELOG.md` stubs are - still committed. The `changesets/action@v1` GitHub Action (the wrapper - around the CLI used in `release.yml`) reads each bumped package's - `CHANGELOG.md` to build the Version Packages PR description and crashes - with `ENOENT` if any are missing. The stubs satisfy that read; the - changesets CLI alone doesn't need them. +- Only the Version Packages PR should change `apps/cli/package.json`; the rest of the workspace is not version-synced for release PRs. +- Per-package `CHANGELOG.md` files are seeded for every workspace package + (`bun run lint:changelog-stubs --fix`). `changeset version` inserts + generated sections after the H1, and the `changesets/action@v1` GitHub + Action reads each bumped package's `CHANGELOG.md` to build the Version + Packages PR description (it crashes with `ENOENT` if any are missing). +- `@changesets/changelog-github` needs a `GITHUB_TOKEN` when running + `changeset version` (it resolves PR numbers and authors). CI provides + one; locally use `GITHUB_TOKEN=$(gh auth token) bun run changeset:version`. - The publish workflow supports either npm trusted publishing or an `NPM_TOKEN` secret. - Re-running the publish workflow for the same tag is safe for packages that are already on npm; existing versions are skipped. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 975c533bf..da1896af7 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,71 @@ -# executor changelog +# executor -This file exists so Changesets' release PR workflow can update package release metadata. +## 1.5.2 -Canonical user-facing release notes are published on GitHub Releases. +### Patch Changes + +- [#936](https://github.com/RhysSullivan/executor/pull/936) [`2db9d65`](https://github.com/RhysSullivan/executor/commit/2db9d65a828615c2ec0b209d54616dbf4264fefd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Desktop** + - Fixed the desktop app failing to launch: the packaged sidecar was missing its native SQLite and keychain bindings, so the local server exited before the window appeared. The release pipeline now smoke-tests the compiled sidecar before publishing. + - Mac auto-updates now serve the correct architecture — the arm64 and x64 update manifests previously collided, so Apple Silicon machines could be offered Intel builds. + - If the local server fails to start, the app now shows the error (with a pointer to the log) and installs any available update on quit, instead of closing silently. + + **Integrations & auth** + - Integrations can declare multiple authentication methods in every plugin. MCP servers join the slugged template model used by OpenAPI and GraphQL, so a server can offer OAuth and an API key side by side, and adding a custom method appends instead of replacing a detected one. Existing connections keep working with no migration. + - OAuth app management is folded into the connect modal, so client setup happens where accounts are added. + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/runtime-quickjs@1.5.2 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.24 + +## 1.5.1 + +### Patch Changes + +- [#927](https://github.com/RhysSullivan/executor/pull/927) [`df40cd3`](https://github.com/RhysSullivan/executor/commit/df40cd3716254daff0343ace7c2de7d46756d0f5) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Fix `executor web` crashing with `no such table: plugin_storage` when upgrading from an older v1 release. The v1 → v2 data migration now replays the bundled legacy schema migrations first, so databases last touched by any pre-1.5 version are brought up to the final v1 schema before their data is migrated. + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + - @executor-js/runtime-quickjs@1.5.1 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.23 + +## 1.5.0 + +### Minor Changes + +- [`c7bb2a4`](https://github.com/RhysSullivan/executor/commit/c7bb2a4da99aac4199b424d6d52e6ea843250e3a) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Integrations and connections rework. + + **Highlights** + - Sources are now split into integrations (the API surface) and connections (the credential). One integration can hold many connections — workspace-shared or personal — and each connection gets its own tool catalog. + - Tool addresses carry the connection, so agents can target a specific account: `tools.vercel_api.org.workspace.deploy` vs `tools.vercel_api.user.personal.deploy`. + - Existing data migrates automatically on first launch: sources become integrations, secrets and credential bindings become connections, OAuth apps and tool policies carry over, and the previous database is kept as a backup next to the new one. + - Public no-auth servers (MCP, GraphQL) connect without entering a credential. + - Connections display the signed-in identity, so you can tell accounts apart at a glance. + - The CLI, local web app, and desktop app can connect to a shared Executor server instead of each running their own; the desktop app persists server profiles across restarts. + - Self-hosted Executor now publishes a multi-architecture GHCR image at `ghcr.io/rhyssullivan/executor-selfhost` (stable releases tagged `latest`, prereleases tagged `beta`). + + **Reliability** + - OpenAPI, GraphQL, and MCP tools return structured authentication failures with recovery guidance instead of opaque internal errors — covering missing credentials, expired OAuth connections, upstream 401/403 responses, and MCP per-user isolation. + - OAuth popups complete more reliably in Chrome by preserving the callback channel through the same-origin completion page. + - OAuth Dynamic Client Registration data is reused across retries and reconnects, including scopes, so providers are not asked to register duplicate clients. + - Creating a connection with invalid input (no credential for a credentialed method, mixed input origins) returns a clear error with the reason instead of an opaque internal error. + - The v1 → v2 migration creates connections for no-auth sources, derives OAuth authorize endpoints when v1 only stored a bare issuer origin, keys inline header values per source, and skips malformed credential bindings with a warning instead of silently dropping them. An unreachable OAuth metadata endpoint no longer blocks the migration on launch. + - Google sources use a bundled OpenAPI flow with valid schemas. + - MCP tool output schemas match the actual invocation result envelope, including `content`, `structuredContent`, `_meta`, and `isError`. + - Integration icons survive migration, connected presets show their icons, and credentials show a loading badge while resolving. + + **Breaking changes** + - Tool addresses gained two segments for the connection's owner and name: `tools.vercel_api.deploy` is now `tools.vercel_api.org.workspace.deploy`. Saved tool policies are rewritten automatically during migration; agent code that hard-codes v1.4 addresses needs the new shape (`tools.search()` returns ready-to-call paths). + - The Google Discovery plugin was removed. Google integrations now go through the bundled Google flow; existing Google sources migrate automatically. + +### Patch Changes + +- [#922](https://github.com/RhysSullivan/executor/pull/922) [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Move `effect` from `dependencies` to `peerDependencies` in the published library packages so consumers provide a single shared Effect instance. + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]: + - @executor-js/sdk@1.5.0 + - @executor-js/runtime-quickjs@1.5.0 + - @executor-js/local@1.4.4 + - @executor-js/api@1.4.22 diff --git a/apps/cli/package.json b/apps/cli/package.json index 71aec20b3..d8d541407 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.4.33", + "version": "1.5.2", "private": true, "bin": { "executor": "./bin/executor.ts" diff --git a/apps/cli/release-notes/next.md b/apps/cli/release-notes/next.md deleted file mode 100644 index f0d91fd1c..000000000 --- a/apps/cli/release-notes/next.md +++ /dev/null @@ -1,23 +0,0 @@ -## Highlights - -### More reliable connected tools - -- OpenAPI, GraphQL, and MCP tools now return structured authentication failures with recovery guidance instead of opaque internal errors. -- OAuth popups now complete more reliably in Chrome by preserving the callback channel through the same-origin completion page. -- OAuth Dynamic Client Registration data is reused across retries and reconnects, including scopes, so providers are not asked to register duplicate clients. -- MCP tool output schemas now match the actual invocation result envelope, including `content`, `structuredContent`, `_meta`, and `isError`. - -## UI - -- No UI-only changes in this patch. - -## Fixes - -- Auth failures from secret-backed and OAuth-backed tools now include model-visible next steps for missing credentials, missing secrets, expired OAuth connections, upstream 401/403 responses, and MCP per-user isolation cases. -- Retrying OAuth sign-in no longer starts an avoidable second Dynamic Client Registration request. -- Reconnecting an OAuth source keeps the previously registered DCR scope list intact. -- MCP sources now describe output types as Executor's full successful `CallToolResult` data shape instead of only the upstream `structuredContent` schema. - -## Breaking changes - -None. diff --git a/apps/cli/src/build.ts b/apps/cli/src/build.ts index f1c7fd511..74ec74394 100644 --- a/apps/cli/src/build.ts +++ b/apps/cli/src/build.ts @@ -252,13 +252,15 @@ const createEmbeddedWebUISource = async (mode: BuildMode) => { }; // --------------------------------------------------------------------------- -// Embedded drizzle migrations — inlined as text imports so drizzle's +// Embedded legacy v1 drizzle migrations — inlined as text imports so drizzle's // `migrate()` (which reads a folder from disk) can be given a tmpdir -// populated from the inlined contents at runtime. +// populated from the inlined contents at runtime. The v1→v2 data migration +// replays this chain to bring an older v1 database up to v1-final before +// reading it (v2's own schema is created from FumaDB DDL, not this folder). // --------------------------------------------------------------------------- const createEmbeddedMigrationsSource = async () => { - const migrationsDir = resolve(webRoot, "drizzle"); + const migrationsDir = resolve(webRoot, "drizzle-legacy-v1"); const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: migrationsDir }))) .map((f) => f.replaceAll("\\", "/")) .sort(); diff --git a/apps/cli/src/daemon-state.test.ts b/apps/cli/src/daemon-state.test.ts index 482b2f8a9..6d7b108c2 100644 --- a/apps/cli/src/daemon-state.test.ts +++ b/apps/cli/src/daemon-state.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import * as Effect from "effect/Effect"; import { @@ -44,7 +44,7 @@ describe("daemon host and scope identity", () => { process.chdir(workspace); process.env.EXECUTOR_SCOPE_DIR = "executor.jsonc"; - expect(currentDaemonScopeId()).toBe(`scope:${join(workspace, "executor.jsonc")}`); + expect(currentDaemonScopeId()).toBe(`scope:${resolve("executor.jsonc")}`); } finally { rmSync(workspace, { recursive: true, force: true }); } diff --git a/apps/cli/src/daemon.test.ts b/apps/cli/src/daemon.test.ts index 69c663680..4f4e3963e 100644 --- a/apps/cli/src/daemon.test.ts +++ b/apps/cli/src/daemon.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import * as Effect from "effect/Effect"; -import { canAutoStartLocalDaemonForHost } from "./daemon"; +import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon"; describe("canAutoStartLocalDaemonForHost", () => { it("allows loopback hosts", () => { @@ -14,3 +17,53 @@ describe("canAutoStartLocalDaemonForHost", () => { expect(canAutoStartLocalDaemonForHost("::")).toBe(false); }); }); + +describe("isExecutorServerReachable", () => { + it.effect("checks the v1.5 API surface instead of the removed scope endpoint", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.tryPromise( + () => + new Promise<{ server: Server; port: number }>((resolve, reject) => { + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + if (url.pathname === "/api/integrations") { + response.writeHead(200, { "content-type": "application/json" }); + response.end("[]"); + return; + } + response.writeHead(404); + response.end(); + }); + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + const address = server.address() as AddressInfo; + resolve({ server, port: address.port }); + }); + }), + ), + ({ server }) => + Effect.tryPromise( + () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + + const legacyScopeStatus = yield* Effect.tryPromise(() => + fetch(`http://127.0.0.1:${server.port}/api/scope`), + ).pipe(Effect.map((response) => response.status)); + + expect(legacyScopeStatus).toBe(404); + + const reachable = yield* isExecutorServerReachable({ + baseUrl: `http://127.0.0.1:${server.port}`, + }); + + expect(reachable).toBe(true); + }), + ); +}); diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index 666f869e6..8be7db064 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -17,6 +17,11 @@ export interface DaemonSpawnSpec { readonly args: ReadonlyArray; } +export interface ExecutorServerReachabilityInput { + readonly baseUrl: string; + readonly authorization?: string; +} + type ProbeServer = ReturnType & { removeAllListeners: () => void; once: (event: "error" | "listening", listener: (...args: unknown[]) => void) => void; @@ -60,6 +65,19 @@ export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => { return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js"); }; +export const isExecutorServerReachable = ( + input: ExecutorServerReachabilityInput, +): Effect.Effect => + Effect.tryPromise(async () => { + const url = new URL("/api/integrations", input.baseUrl); + const response = await fetch(url, { + ...(input.authorization ? { headers: { authorization: input.authorization } } : {}), + signal: AbortSignal.timeout(2000), + }); + await response.body?.cancel(); + return response.ok; + }).pipe(Effect.catchCause(() => Effect.succeed(false))); + // --------------------------------------------------------------------------- // Process spec // --------------------------------------------------------------------------- diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index cd27c3f90..e64bfb2d7 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -62,6 +62,7 @@ import { buildDaemonSpawnSpec, chooseDaemonPort, canAutoStartLocalDaemonForHost, + isExecutorServerReachable, isDevCliEntrypoint, parseDaemonBaseUrl, spawnDetached, @@ -157,51 +158,8 @@ const waitForShutdownSignal = () => // Background server management // --------------------------------------------------------------------------- -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -interface DaemonScopeInfo { - readonly id: string; - readonly name: string; - readonly dir: string; -} - -const readDaemonScopeInfo = ( - baseUrl: string, - authorization?: string, -): Effect.Effect => - Effect.tryPromise(() => - fetch(`${baseUrl}/api/scope`, { - ...(authorization ? { headers: { authorization } } : {}), - signal: AbortSignal.timeout(2000), - }), - ).pipe( - Effect.flatMap((res) => { - if (!res.ok) return Effect.succeed(null); - return Effect.tryPromise(() => res.json()).pipe( - Effect.map((payload) => { - if (!isRecord(payload)) return null; - if ( - typeof payload.id === "string" && - typeof payload.name === "string" && - typeof payload.dir === "string" - ) { - return { - id: payload.id, - name: payload.name, - dir: payload.dir, - }; - } - return null; - }), - Effect.catchCause(() => Effect.succeed(null)), - ); - }), - Effect.catchCause(() => Effect.succeed(null)), - ); - const isServerReachable = (baseUrl: string, authorization?: string): Effect.Effect => - readDaemonScopeInfo(baseUrl, authorization).pipe(Effect.map((scopeInfo) => scopeInfo !== null)); + isExecutorServerReachable({ baseUrl, authorization }); const readActiveLocalServerManifest = (): Effect.Effect< ExecutorLocalServerManifest | null, @@ -238,9 +196,6 @@ const normalizeDaemonScopeDir = (dir: string): string => { return existsSync(resolved) ? realpathSync.native(resolved) : resolved; }; -const currentDaemonScopeDir = (): string => - normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR ?? process.cwd()); - const currentScopeDirForManifest = (): string | null => process.env.EXECUTOR_SCOPE_DIR ? normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR) : null; @@ -382,6 +337,7 @@ const resolveDaemonTarget = (baseUrl: string) => hostname: pointer.hostname, port: pointer.port, scopeId, + fromPointer: true, }; } @@ -393,6 +349,7 @@ const resolveDaemonTarget = (baseUrl: string) => hostname: host, port: parsed.port, scopeId, + fromPointer: false, }; }); @@ -484,17 +441,22 @@ const ensureDaemon = ( ): Effect.Effect => Effect.gen(function* () { const resolvedTarget = yield* resolveDaemonTarget(baseUrl); - const reachableScope = yield* readDaemonScopeInfo(resolvedTarget.baseUrl); - if (reachableScope && normalizeDaemonScopeDir(reachableScope.dir) === currentDaemonScopeDir()) { + if (resolvedTarget.fromPointer && (yield* isServerReachable(resolvedTarget.baseUrl))) { return resolvedTarget.baseUrl; } const active = yield* readActiveLocalServerManifest(); - if ( - active && - normalizeExecutorServerConnection({ origin: active.connection.origin }).origin !== - normalizeExecutorServerConnection({ origin: resolvedTarget.baseUrl }).origin - ) { + const activeOrigin = active + ? normalizeExecutorServerConnection({ origin: active.connection.origin }).origin + : null; + const targetOrigin = normalizeExecutorServerConnection({ + origin: resolvedTarget.baseUrl, + }).origin; + if (activeOrigin === targetOrigin) { + return resolvedTarget.baseUrl; + } + + if (active && activeOrigin !== targetOrigin) { return yield* Effect.fail( new Error( [ @@ -1507,9 +1469,8 @@ const runCallHelp = ( serverName: args.serverName, }); const client = yield* makeApiClient(connection); - const scopeInfo = yield* client.scope.info(); - const tools = yield* client.tools.list({ params: { scopeId: scopeInfo.id } }); - const toolPaths = tools.map((tool) => tool.id); + const tools = yield* client.tools.list({ query: {} }); + const toolPaths = tools.map((tool) => tool.address); const inspection = yield* Effect.try({ try: () => @@ -1575,15 +1536,14 @@ const runCallHelp = ( } const exactTool = inspection.exactPath - ? tools.find((tool) => tool.id === inspection.exactPath) + ? tools.find((tool) => tool.address === inspection.exactPath) : undefined; if (exactTool && inspection.children.length === 0) { const schema = yield* client.tools .schema({ - params: { - scopeId: scopeInfo.id, - toolId: exactTool.id, + query: { + address: exactTool.address, }, }) .pipe( @@ -1596,7 +1556,7 @@ const runCallHelp = ( yield* printCallLeafHelp({ tool: { - id: exactTool.id, + id: exactTool.address, description: exactTool.description, }, schema, @@ -1618,7 +1578,7 @@ const runCallHelp = ( limit: args.limit, exactTool: exactTool ? { - id: exactTool.id, + id: exactTool.address, description: exactTool.description, } : undefined, diff --git a/apps/cli/src/release.ts b/apps/cli/src/release.ts index 2fc530d26..4471fe56f 100644 --- a/apps/cli/src/release.ts +++ b/apps/cli/src/release.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, readdir, rename, rm } from "node:fs/promises"; +import { mkdir, readdir, readFile, rename, rm } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -167,6 +167,30 @@ const githubReleaseExists = async (tag: string, repository: string): Promise => { + const changelogPath = resolve(cliRoot, "CHANGELOG.md"); + if (!existsSync(changelogPath)) return null; + const lines = (await readFile(changelogPath, "utf8")).split("\n"); + const start = lines.findIndex((line) => line.trim() === `## ${version}`); + if (start === -1) return null; + let end = lines.length; + for (let i = start + 1; i < lines.length; i += 1) { + if (/^## /.test(lines[i] ?? "")) { + end = i; + break; + } + } + const body = lines + .slice(start + 1, end) + .join("\n") + .trim(); + return body.length > 0 ? body : null; +}; + const syncGitHubRelease = async (input: { readonly tag: string; readonly channel: ReleaseChannel; @@ -195,10 +219,7 @@ const syncGitHubRelease = async (input: { return; } - // Single rolling release-notes file. Historical release bodies live on - // GitHub Releases — we don't archive per-version copies in the repo. - const notesPath = resolve(cliRoot, "release-notes", "next.md"); - const notesFile = existsSync(notesPath) ? notesPath : null; + const notes = await changelogSectionForVersion(input.tag.replace(/^v/, "")); // Draft until publish-desktop.yml finishes uploading installers and flips // it; otherwise /releases/latest/download/ 404s during the @@ -212,7 +233,7 @@ const syncGitHubRelease = async (input: { repository, "--title", input.tag, - ...(notesFile ? ["--notes-file", notesFile] : ["--generate-notes"]), + ...(notes ? ["--notes", notes] : ["--generate-notes"]), "--verify-tag", "--draft", ]; diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index dfa5b667c..c3cf4e234 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1 +1,58 @@ # @executor-js/cloud + +## 1.4.22 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/runtime-quickjs@1.5.2 + - @executor-js/execution@1.5.2 + - @executor-js/plugin-graphql@1.5.2 + - @executor-js/plugin-mcp@1.5.2 + - @executor-js/plugin-openapi@1.5.2 + - @executor-js/api@1.4.24 + - @executor-js/vite-plugin@0.0.21 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.24 + - @executor-js/cloudflare@0.0.3 + +## 1.4.21 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + - @executor-js/runtime-quickjs@1.5.1 + - @executor-js/execution@1.5.1 + - @executor-js/plugin-graphql@1.5.1 + - @executor-js/plugin-mcp@1.5.1 + - @executor-js/plugin-openapi@1.5.1 + - @executor-js/api@1.4.23 + - @executor-js/vite-plugin@0.0.20 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.23 + - @executor-js/cloudflare@0.0.2 + +## 1.4.20 + +### Patch Changes + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad), [`9c9bcb6`](https://github.com/RhysSullivan/executor/commit/9c9bcb663e48ebb21a71f8058812319c1ec2a242)]: + - @executor-js/sdk@1.5.0 + - @executor-js/plugin-openapi@1.5.0 + - @executor-js/execution@1.5.0 + - @executor-js/plugin-graphql@1.5.0 + - @executor-js/plugin-mcp@1.5.0 + - @executor-js/runtime-quickjs@1.5.0 + - @executor-js/api@1.4.22 + - @executor-js/vite-plugin@0.0.19 + - @executor-js/host-mcp@1.4.4 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.22 + - @executor-js/cloudflare@0.0.1 diff --git a/apps/cloud/drizzle.config.ts b/apps/cloud/drizzle.config.ts index 6b8a2463b..594a04c09 100644 --- a/apps/cloud/drizzle.config.ts +++ b/apps/cloud/drizzle.config.ts @@ -15,7 +15,7 @@ const withSslMode = (url: string): string => { }; export default defineConfig({ - schema: ["./src/services/schema.ts", "./src/services/executor-schema.ts"], + schema: ["./src/db/schema.ts", "./src/db/executor-schema.ts"], out: "./drizzle", dialect: "postgresql", dbCredentials: { diff --git a/apps/cloud/drizzle/0000_lame_rage.sql b/apps/cloud/drizzle/0000_lame_rage.sql deleted file mode 100644 index b2931e056..000000000 --- a/apps/cloud/drizzle/0000_lame_rage.sql +++ /dev/null @@ -1,179 +0,0 @@ -CREATE TABLE "accounts" ( - "id" text PRIMARY KEY NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "memberships" ( - "account_id" text NOT NULL, - "organization_id" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "memberships_account_id_organization_id_pk" PRIMARY KEY("account_id","organization_id") -); ---> statement-breakpoint -CREATE TABLE "organizations" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE "blob" ( - "namespace" text NOT NULL, - "key" text NOT NULL, - "value" text NOT NULL, - CONSTRAINT "blob_namespace_key_pk" PRIMARY KEY("namespace","key") -); ---> statement-breakpoint -CREATE TABLE "definition" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "plugin_id" text NOT NULL, - "name" text NOT NULL, - "schema" jsonb NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "definition_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "graphql_operation" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "binding" jsonb NOT NULL, - CONSTRAINT "graphql_operation_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "graphql_source" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "name" text NOT NULL, - "endpoint" text NOT NULL, - "headers" jsonb, - CONSTRAINT "graphql_source_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "mcp_binding" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "binding" jsonb NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "mcp_binding_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "mcp_oauth_session" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "session" jsonb NOT NULL, - "expires_at" integer NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "mcp_oauth_session_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "mcp_source" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "name" text NOT NULL, - "config" jsonb NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "mcp_source_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "openapi_oauth_session" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "session" jsonb NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "openapi_oauth_session_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "openapi_operation" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "binding" jsonb NOT NULL, - CONSTRAINT "openapi_operation_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "openapi_source" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "name" text NOT NULL, - "spec" text NOT NULL, - "base_url" text, - "headers" jsonb, - "oauth2" jsonb, - "invocation_config" jsonb NOT NULL, - CONSTRAINT "openapi_source_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "secret" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "name" text NOT NULL, - "provider" text NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "secret_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "source" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "plugin_id" text NOT NULL, - "kind" text NOT NULL, - "name" text NOT NULL, - "url" text, - "can_remove" boolean DEFAULT true NOT NULL, - "can_refresh" boolean DEFAULT false NOT NULL, - "can_edit" boolean DEFAULT false NOT NULL, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "source_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "tool" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "plugin_id" text NOT NULL, - "name" text NOT NULL, - "description" text NOT NULL, - "input_schema" jsonb, - "output_schema" jsonb, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "tool_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE TABLE "workos_vault_metadata" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "name" text NOT NULL, - "purpose" text, - "created_at" timestamp NOT NULL, - CONSTRAINT "workos_vault_metadata_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -ALTER TABLE "memberships" ADD CONSTRAINT "memberships_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "definition_scope_id_idx" ON "definition" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "definition_source_id_idx" ON "definition" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "definition_plugin_id_idx" ON "definition" USING btree ("plugin_id");--> statement-breakpoint -CREATE INDEX "graphql_operation_scope_id_idx" ON "graphql_operation" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "graphql_operation_source_id_idx" ON "graphql_operation" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "graphql_source_scope_id_idx" ON "graphql_source" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "mcp_binding_scope_id_idx" ON "mcp_binding" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "mcp_binding_source_id_idx" ON "mcp_binding" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "mcp_oauth_session_scope_id_idx" ON "mcp_oauth_session" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "mcp_source_scope_id_idx" ON "mcp_source" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "openapi_oauth_session_scope_id_idx" ON "openapi_oauth_session" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "openapi_operation_scope_id_idx" ON "openapi_operation" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "openapi_operation_source_id_idx" ON "openapi_operation" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "openapi_source_scope_id_idx" ON "openapi_source" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "secret_scope_id_idx" ON "secret" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "secret_provider_idx" ON "secret" USING btree ("provider");--> statement-breakpoint -CREATE INDEX "source_scope_id_idx" ON "source" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "source_plugin_id_idx" ON "source" USING btree ("plugin_id");--> statement-breakpoint -CREATE INDEX "tool_scope_id_idx" ON "tool" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "tool_source_id_idx" ON "tool" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "tool_plugin_id_idx" ON "tool" USING btree ("plugin_id");--> statement-breakpoint -CREATE INDEX "workos_vault_metadata_scope_id_idx" ON "workos_vault_metadata" USING btree ("scope_id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0000_v2_baseline.sql b/apps/cloud/drizzle/0000_v2_baseline.sql new file mode 100644 index 000000000..8a173cbe0 --- /dev/null +++ b/apps/cloud/drizzle/0000_v2_baseline.sql @@ -0,0 +1,165 @@ +CREATE TABLE "accounts" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "memberships" ( + "account_id" text NOT NULL, + "organization_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "memberships_account_id_organization_id_pk" PRIMARY KEY("account_id","organization_id") +); +--> statement-breakpoint +CREATE TABLE "organizations" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "blob" ( + "namespace" varchar(255) NOT NULL, + "key" varchar(255) NOT NULL, + "value" text NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "id" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "connection" ( + "integration" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "template" text NOT NULL, + "provider" text NOT NULL, + "item_ids" json NOT NULL, + "identity_label" text, + "oauth_client" text, + "oauth_client_owner" text, + "refresh_item_id" text, + "expires_at" bigint, + "oauth_scope" text, + "provider_state" json, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "definition" ( + "integration" varchar(255) NOT NULL, + "connection" varchar(255) NOT NULL, + "plugin_id" text NOT NULL, + "name" text NOT NULL, + "schema" json NOT NULL, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "integration" ( + "slug" varchar(255) NOT NULL, + "plugin_id" text NOT NULL, + "description" text NOT NULL, + "config" json, + "can_remove" boolean DEFAULT true NOT NULL, + "can_refresh" boolean DEFAULT false NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "oauth_client" ( + "slug" varchar(255) NOT NULL, + "authorization_url" text NOT NULL, + "token_url" text NOT NULL, + "grant" text NOT NULL, + "client_id" text NOT NULL, + "client_secret_item_id" text, + "resource" text, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "oauth_session" ( + "state" varchar(255) NOT NULL, + "client_slug" text NOT NULL, + "integration" text NOT NULL, + "name" text NOT NULL, + "template" text NOT NULL, + "redirect_url" text NOT NULL, + "pkce_verifier" text, + "identity_label" text, + "payload" json NOT NULL, + "expires_at" bigint NOT NULL, + "created_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_storage" ( + "plugin_id" varchar(255) NOT NULL, + "collection" varchar(255) NOT NULL, + "key" varchar(255) NOT NULL, + "data" json NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "private_executor_cloud_settings" ( + "id" varchar(255) PRIMARY KEY NOT NULL, + "version" varchar(255) DEFAULT '1.0.0' NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tool" ( + "integration" varchar(255) NOT NULL, + "connection" varchar(255) NOT NULL, + "plugin_id" text NOT NULL, + "name" varchar(255) NOT NULL, + "description" text NOT NULL, + "input_schema" json, + "output_schema" json, + "annotations" json, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tool_policy" ( + "id" varchar(255) NOT NULL, + "pattern" text NOT NULL, + "action" text NOT NULL, + "position" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "row_id" varchar(255) PRIMARY KEY NOT NULL, + "tenant" varchar(255) NOT NULL, + "owner" varchar(255) NOT NULL, + "subject" varchar(255) NOT NULL +); +--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "blob_id_uidx" ON "blob" USING btree ("id");--> statement-breakpoint +CREATE UNIQUE INDEX "connection_uidx" ON "connection" USING btree ("tenant","owner","subject","integration","name");--> statement-breakpoint +CREATE UNIQUE INDEX "definition_uidx" ON "definition" USING btree ("tenant","owner","subject","integration","connection","name");--> statement-breakpoint +CREATE UNIQUE INDEX "integration_uidx" ON "integration" USING btree ("tenant","slug");--> statement-breakpoint +CREATE UNIQUE INDEX "oauth_client_uidx" ON "oauth_client" USING btree ("tenant","owner","subject","slug");--> statement-breakpoint +CREATE UNIQUE INDEX "oauth_session_uidx" ON "oauth_session" USING btree ("tenant","state");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_storage_uidx" ON "plugin_storage" USING btree ("tenant","owner","subject","plugin_id","collection","key");--> statement-breakpoint +CREATE UNIQUE INDEX "tool_uidx" ON "tool" USING btree ("tenant","owner","subject","integration","connection","name");--> statement-breakpoint +CREATE UNIQUE INDEX "tool_policy_uidx" ON "tool_policy" USING btree ("tenant","owner","subject","id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0001_harsh_meltdown.sql b/apps/cloud/drizzle/0001_harsh_meltdown.sql deleted file mode 100644 index 290c74a4f..000000000 --- a/apps/cloud/drizzle/0001_harsh_meltdown.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "openapi_source" ADD COLUMN "source_url" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/0001_illegal_wolverine.sql b/apps/cloud/drizzle/0001_illegal_wolverine.sql new file mode 100644 index 000000000..16aa92ceb --- /dev/null +++ b/apps/cloud/drizzle/0001_illegal_wolverine.sql @@ -0,0 +1,2 @@ +ALTER TABLE "oauth_client" ADD COLUMN "origin_kind" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "origin_integration" text; diff --git a/apps/cloud/drizzle/0002_fat_white_tiger.sql b/apps/cloud/drizzle/0002_fat_white_tiger.sql deleted file mode 100644 index d3b0fd7fa..000000000 --- a/apps/cloud/drizzle/0002_fat_white_tiger.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "mcp_oauth_session" ALTER COLUMN "expires_at" SET DATA TYPE bigint; \ No newline at end of file diff --git a/apps/cloud/drizzle/0003_add_connections.sql b/apps/cloud/drizzle/0003_add_connections.sql deleted file mode 100644 index ef7e9d03b..000000000 --- a/apps/cloud/drizzle/0003_add_connections.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE "connection" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "provider" text NOT NULL, - "kind" text NOT NULL, - "identity_label" text, - "access_token_secret_id" text NOT NULL, - "refresh_token_secret_id" text, - "expires_at" bigint, - "scope" text, - "provider_state" jsonb, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "connection_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -ALTER TABLE "secret" ADD COLUMN "owned_by_connection_id" text;--> statement-breakpoint -CREATE INDEX "connection_scope_id_idx" ON "connection" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "connection_provider_idx" ON "connection" USING btree ("provider");--> statement-breakpoint -CREATE INDEX "secret_owned_by_connection_id_idx" ON "secret" USING btree ("owned_by_connection_id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0004_openapi_source_bindings.sql b/apps/cloud/drizzle/0004_openapi_source_bindings.sql deleted file mode 100644 index ff4f0b2a5..000000000 --- a/apps/cloud/drizzle/0004_openapi_source_bindings.sql +++ /dev/null @@ -1,19 +0,0 @@ -CREATE TABLE "openapi_source_binding" ( - "id" text NOT NULL, - "source_id" text NOT NULL, - "source_scope_id" text NOT NULL, - "target_scope_id" text NOT NULL, - "slot" text NOT NULL, - "value" jsonb NOT NULL, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "openapi_source_binding_id_pk" PRIMARY KEY("id") -); ---> statement-breakpoint -CREATE INDEX "openapi_source_binding_source_id_idx" ON "openapi_source_binding" USING btree ("source_id"); ---> statement-breakpoint -CREATE INDEX "openapi_source_binding_source_scope_id_idx" ON "openapi_source_binding" USING btree ("source_scope_id"); ---> statement-breakpoint -CREATE INDEX "openapi_source_binding_target_scope_id_idx" ON "openapi_source_binding" USING btree ("target_scope_id"); ---> statement-breakpoint -CREATE INDEX "openapi_source_binding_slot_idx" ON "openapi_source_binding" USING btree ("slot"); diff --git a/apps/cloud/drizzle/0005_drop_connection_kind.sql b/apps/cloud/drizzle/0005_drop_connection_kind.sql deleted file mode 100644 index b2846f3d8..000000000 --- a/apps/cloud/drizzle/0005_drop_connection_kind.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "connection" DROP COLUMN "kind"; diff --git a/apps/cloud/drizzle/0006_add_tool_policy.sql b/apps/cloud/drizzle/0006_add_tool_policy.sql deleted file mode 100644 index 8957f9713..000000000 --- a/apps/cloud/drizzle/0006_add_tool_policy.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE "tool_policy" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "pattern" text NOT NULL, - "action" text NOT NULL, - -- Fractional-indexing key (Jira lexorank style). Lex-ordered text; - -- always subdivisible by lengthening, so reorders never run out of - -- room. - "position" text NOT NULL, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "tool_policy_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint --- List queries are always `WHERE scope_id = ? ORDER BY position`, so the --- composite index serves both the filter and the sort from one btree. -CREATE INDEX "tool_policy_scope_id_position_idx" ON "tool_policy" USING btree ("scope_id", "position"); diff --git a/apps/cloud/drizzle/0007_military_young_avengers.sql b/apps/cloud/drizzle/0007_military_young_avengers.sql deleted file mode 100644 index a5e132db1..000000000 --- a/apps/cloud/drizzle/0007_military_young_avengers.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE "oauth2_session" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "plugin_id" text NOT NULL, - "strategy" text NOT NULL, - "connection_id" text NOT NULL, - "token_scope" text NOT NULL, - "redirect_url" text NOT NULL, - "payload" jsonb NOT NULL, - "expires_at" bigint NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "oauth2_session_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -DROP TABLE "mcp_oauth_session" CASCADE;--> statement-breakpoint -DROP TABLE "openapi_oauth_session" CASCADE;--> statement-breakpoint -ALTER TABLE "graphql_source" ADD COLUMN "query_params" jsonb;--> statement-breakpoint -ALTER TABLE "graphql_source" ADD COLUMN "auth" jsonb;--> statement-breakpoint -ALTER TABLE "openapi_source" ADD COLUMN "query_params" jsonb;--> statement-breakpoint -CREATE INDEX "oauth2_session_scope_id_idx" ON "oauth2_session" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "oauth2_session_plugin_id_idx" ON "oauth2_session" USING btree ("plugin_id");--> statement-breakpoint -CREATE INDEX "oauth2_session_connection_id_idx" ON "oauth2_session" USING btree ("connection_id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0008_normalize_plugin_secret_refs.sql b/apps/cloud/drizzle/0008_normalize_plugin_secret_refs.sql deleted file mode 100644 index 6e791621a..000000000 --- a/apps/cloud/drizzle/0008_normalize_plugin_secret_refs.sql +++ /dev/null @@ -1,388 +0,0 @@ --- Normalize all plugin secret/connection refs out of JSON columns --- into proper relational shape: graphql, openapi, mcp. --- pg port of apps/local/drizzle/0007_normalize_plugin_secret_refs.sql. --- (google-discovery is local-only — not in cloud's plugin list.) - --- ============================================================ --- graphql --- ============================================================ - --- Normalize graphql plugin: move secret/connection refs out of JSON --- columns into proper relational shape so usagesForSecret / --- usagesForConnection are one indexed SELECT instead of a JSON scan. --- pg port of apps/local/drizzle/0007_normalize_graphql.sql. - -CREATE TABLE "graphql_source_header" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "secret_prefix" text, - CONSTRAINT "graphql_source_header_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE INDEX "graphql_source_header_scope_id_idx" ON "graphql_source_header" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "graphql_source_header_source_id_idx" ON "graphql_source_header" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "graphql_source_header_secret_id_idx" ON "graphql_source_header" USING btree ("secret_id");--> statement-breakpoint - -CREATE TABLE "graphql_source_query_param" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "secret_prefix" text, - CONSTRAINT "graphql_source_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE INDEX "graphql_source_query_param_scope_id_idx" ON "graphql_source_query_param" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "graphql_source_query_param_source_id_idx" ON "graphql_source_query_param" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "graphql_source_query_param_secret_id_idx" ON "graphql_source_query_param" USING btree ("secret_id");--> statement-breakpoint - --- New auth columns. `auth_kind` defaults to "none" so existing rows that --- predate this migration are valid even if the json was null. -ALTER TABLE "graphql_source" ADD COLUMN "auth_kind" text DEFAULT 'none' NOT NULL;--> statement-breakpoint -ALTER TABLE "graphql_source" ADD COLUMN "auth_connection_id" text;--> statement-breakpoint -CREATE INDEX "graphql_source_auth_connection_id_idx" ON "graphql_source" USING btree ("auth_connection_id");--> statement-breakpoint - --- Backfill auth from the JSON column. Missing keys yield NULL, so a row --- with auth=NULL or kind="none" leaves auth_connection_id NULL and --- auth_kind defaulted to "none". -UPDATE "graphql_source" -SET - "auth_kind" = COALESCE("auth"->>'kind', 'none'), - "auth_connection_id" = "auth"->>'connectionId' -WHERE "auth" IS NOT NULL;--> statement-breakpoint - --- Backfill headers. For each (source, header_name) pair: if the value --- is a json object with .secretId, write a kind=secret row; otherwise --- write a kind=text row with the literal string. jsonb_each iterates --- the keys of the headers object. -INSERT INTO "graphql_source_header" - ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix") -SELECT - s."scope_id", - '[' || to_jsonb(s."id")::text || ',' || to_jsonb(h.key)::text || ']', - s."id", - h.key, - CASE - WHEN jsonb_typeof(h.value) = 'object' AND h.value ? 'secretId' THEN 'secret' - ELSE 'text' - END, - CASE WHEN jsonb_typeof(h.value) = 'string' THEN h.value #>> '{}' ELSE NULL END, - CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'secretId' ELSE NULL END, - CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'prefix' ELSE NULL END -FROM "graphql_source" s, jsonb_each(s."headers") h -WHERE s."headers" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - --- Same for query_params. -INSERT INTO "graphql_source_query_param" - ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix") -SELECT - s."scope_id", - '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']', - s."id", - q.key, - CASE - WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret' - ELSE 'text' - END, - CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END -FROM "graphql_source" s, jsonb_each(s."query_params") q -WHERE s."query_params" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -ALTER TABLE "graphql_source" DROP COLUMN "headers";--> statement-breakpoint -ALTER TABLE "graphql_source" DROP COLUMN "query_params";--> statement-breakpoint -ALTER TABLE "graphql_source" DROP COLUMN "auth"; - ---> statement-breakpoint - --- ============================================================ --- openapi --- ============================================================ - --- Normalize openapi plugin: move every direct secret/connection ref out --- of JSON columns into proper relational shape. pg port of --- apps/local/drizzle/0008_normalize_openapi.sql. - -CREATE TABLE "openapi_source_query_param" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "secret_prefix" text, - CONSTRAINT "openapi_source_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE INDEX "openapi_source_query_param_scope_id_idx" ON "openapi_source_query_param" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "openapi_source_query_param_source_id_idx" ON "openapi_source_query_param" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "openapi_source_query_param_secret_id_idx" ON "openapi_source_query_param" USING btree ("secret_id");--> statement-breakpoint - -CREATE TABLE "openapi_source_spec_fetch_header" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "secret_prefix" text, - CONSTRAINT "openapi_source_spec_fetch_header_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE INDEX "openapi_source_spec_fetch_header_scope_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "openapi_source_spec_fetch_header_source_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "openapi_source_spec_fetch_header_secret_id_idx" ON "openapi_source_spec_fetch_header" USING btree ("secret_id");--> statement-breakpoint - -CREATE TABLE "openapi_source_spec_fetch_query_param" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "secret_prefix" text, - CONSTRAINT "openapi_source_spec_fetch_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE INDEX "openapi_source_spec_fetch_query_param_scope_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "openapi_source_spec_fetch_query_param_source_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "openapi_source_spec_fetch_query_param_secret_id_idx" ON "openapi_source_spec_fetch_query_param" USING btree ("secret_id");--> statement-breakpoint - --- New columns on openapi_source_binding to flatten the value json. --- `kind` defaults to 'text' so the ALTER works on existing rows; the --- backfill below stamps the real value. -ALTER TABLE "openapi_source_binding" ADD COLUMN "kind" text DEFAULT 'text' NOT NULL;--> statement-breakpoint -ALTER TABLE "openapi_source_binding" ADD COLUMN "secret_id" text;--> statement-breakpoint -ALTER TABLE "openapi_source_binding" ADD COLUMN "connection_id" text;--> statement-breakpoint -ALTER TABLE "openapi_source_binding" ADD COLUMN "text_value" text;--> statement-breakpoint -CREATE INDEX "openapi_source_binding_secret_id_idx" ON "openapi_source_binding" USING btree ("secret_id");--> statement-breakpoint -CREATE INDEX "openapi_source_binding_connection_id_idx" ON "openapi_source_binding" USING btree ("connection_id");--> statement-breakpoint - -UPDATE "openapi_source_binding" -SET - "kind" = COALESCE("value"->>'kind', 'text'), - "secret_id" = CASE WHEN "value"->>'kind' = 'secret' THEN "value"->>'secretId' ELSE NULL END, - "connection_id" = CASE WHEN "value"->>'kind' = 'connection' THEN "value"->>'connectionId' ELSE NULL END, - "text_value" = CASE WHEN "value"->>'kind' = 'text' THEN "value"->>'text' ELSE NULL END -WHERE "value" IS NOT NULL;--> statement-breakpoint - -INSERT INTO "openapi_source_query_param" - ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix") -SELECT - s."scope_id", - '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']', - s."id", - q.key, - CASE - WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret' - ELSE 'text' - END, - CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END -FROM "openapi_source" s, jsonb_each(s."query_params") q -WHERE s."query_params" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -INSERT INTO "openapi_source_spec_fetch_header" - ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix") -SELECT - s."scope_id", - '[' || to_jsonb(s."id")::text || ',' || to_jsonb(h.key)::text || ']', - s."id", - h.key, - CASE - WHEN jsonb_typeof(h.value) = 'object' AND h.value ? 'secretId' THEN 'secret' - ELSE 'text' - END, - CASE WHEN jsonb_typeof(h.value) = 'string' THEN h.value #>> '{}' ELSE NULL END, - CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'secretId' ELSE NULL END, - CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'prefix' ELSE NULL END -FROM "openapi_source" s, jsonb_each(s."invocation_config"->'specFetchCredentials'->'headers') h -WHERE s."invocation_config"->'specFetchCredentials'->'headers' IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -INSERT INTO "openapi_source_spec_fetch_query_param" - ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix") -SELECT - s."scope_id", - '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']', - s."id", - q.key, - CASE - WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret' - ELSE 'text' - END, - CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END -FROM "openapi_source" s, jsonb_each(s."invocation_config"->'specFetchCredentials'->'queryParams') q -WHERE s."invocation_config"->'specFetchCredentials'->'queryParams' IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - --- Preserve any legacy OAuth payload from invocation_config.oauth2 into --- the still-existing oauth2 column before we drop invocation_config. --- migrateLegacyConnections runs after drizzle migrations and reads --- oauth2 to detect the legacy shape; without this, rows that only had --- their OAuth payload under invocation_config.oauth2 would lose it. -UPDATE "openapi_source" -SET "oauth2" = "invocation_config"->'oauth2' -WHERE "oauth2" IS NULL - AND "invocation_config"->'oauth2' IS NOT NULL;--> statement-breakpoint - -ALTER TABLE "openapi_source_binding" DROP COLUMN "value";--> statement-breakpoint -ALTER TABLE "openapi_source" DROP COLUMN "query_params";--> statement-breakpoint -ALTER TABLE "openapi_source" DROP COLUMN "invocation_config"; - ---> statement-breakpoint - --- ============================================================ --- mcp --- ============================================================ - --- Normalize mcp plugin: lift the McpConnectionAuth secret/connection --- refs and the SecretBackedMap headers/query_params out of --- mcp_source.config JSON into proper columns / child tables. pg port --- of apps/local/drizzle/0009_normalize_mcp.sql. - -CREATE TABLE "mcp_source_header" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "secret_prefix" text, - CONSTRAINT "mcp_source_header_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE INDEX "mcp_source_header_scope_id_idx" ON "mcp_source_header" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "mcp_source_header_source_id_idx" ON "mcp_source_header" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "mcp_source_header_secret_id_idx" ON "mcp_source_header" USING btree ("secret_id");--> statement-breakpoint - -CREATE TABLE "mcp_source_query_param" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "secret_prefix" text, - CONSTRAINT "mcp_source_query_param_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -CREATE INDEX "mcp_source_query_param_scope_id_idx" ON "mcp_source_query_param" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "mcp_source_query_param_source_id_idx" ON "mcp_source_query_param" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "mcp_source_query_param_secret_id_idx" ON "mcp_source_query_param" USING btree ("secret_id");--> statement-breakpoint - -ALTER TABLE "mcp_source" ADD COLUMN "auth_kind" text DEFAULT 'none' NOT NULL;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_header_name" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_secret_id" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_secret_prefix" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_connection_id" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_client_id_secret_id" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_client_secret_secret_id" text;--> statement-breakpoint -CREATE INDEX "mcp_source_auth_secret_id_idx" ON "mcp_source" USING btree ("auth_secret_id");--> statement-breakpoint -CREATE INDEX "mcp_source_auth_connection_id_idx" ON "mcp_source" USING btree ("auth_connection_id");--> statement-breakpoint -CREATE INDEX "mcp_source_auth_client_id_secret_id_idx" ON "mcp_source" USING btree ("auth_client_id_secret_id");--> statement-breakpoint -CREATE INDEX "mcp_source_auth_client_secret_secret_id_idx" ON "mcp_source" USING btree ("auth_client_secret_secret_id");--> statement-breakpoint - --- Only update rows with explicitly current-shape auth (kind=header w/ --- secretId, or kind=oauth2 w/ connectionId). Legacy inline-OAuth rows --- are left untouched so the post-migrate migrateLegacyConnections --- script can convert them to a Connection. -UPDATE "mcp_source" -SET - "auth_kind" = "config"#>>'{auth,kind}', - "auth_header_name" = "config"#>>'{auth,headerName}', - "auth_secret_id" = "config"#>>'{auth,secretId}', - "auth_secret_prefix" = "config"#>>'{auth,prefix}', - "auth_connection_id" = "config"#>>'{auth,connectionId}', - "auth_client_id_secret_id" = "config"#>>'{auth,clientIdSecretId}', - "auth_client_secret_secret_id" = "config"#>>'{auth,clientSecretSecretId}' -WHERE "config" IS NOT NULL - AND ( - ( - "config"#>>'{auth,kind}' = 'header' - AND "config"#>>'{auth,secretId}' IS NOT NULL - ) - OR ( - "config"#>>'{auth,kind}' = 'oauth2' - AND "config"#>>'{auth,connectionId}' IS NOT NULL - ) - );--> statement-breakpoint - -INSERT INTO "mcp_source_header" - ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix") -SELECT - s."scope_id", - '[' || to_jsonb(s."id")::text || ',' || to_jsonb(h.key)::text || ']', - s."id", - h.key, - CASE - WHEN jsonb_typeof(h.value) = 'object' AND h.value ? 'secretId' THEN 'secret' - ELSE 'text' - END, - CASE WHEN jsonb_typeof(h.value) = 'string' THEN h.value #>> '{}' ELSE NULL END, - CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'secretId' ELSE NULL END, - CASE WHEN jsonb_typeof(h.value) = 'object' THEN h.value->>'prefix' ELSE NULL END -FROM "mcp_source" s, jsonb_each(s."config"->'headers') h -WHERE s."config"->'headers' IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -INSERT INTO "mcp_source_query_param" - ("scope_id", "id", "source_id", "name", "kind", "text_value", "secret_id", "secret_prefix") -SELECT - s."scope_id", - '[' || to_jsonb(s."id")::text || ',' || to_jsonb(q.key)::text || ']', - s."id", - q.key, - CASE - WHEN jsonb_typeof(q.value) = 'object' AND q.value ? 'secretId' THEN 'secret' - ELSE 'text' - END, - CASE WHEN jsonb_typeof(q.value) = 'string' THEN q.value #>> '{}' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'secretId' ELSE NULL END, - CASE WHEN jsonb_typeof(q.value) = 'object' THEN q.value->>'prefix' ELSE NULL END -FROM "mcp_source" s, jsonb_each(s."config"->'queryParams') q -WHERE s."config"->'queryParams' IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - --- Strip already-copied fields from config JSON. headers/queryParams --- are always safe; auth is only stripped on rows whose auth was the --- current shape (legacy inline-OAuth rows keep config.auth so --- migrateLegacyConnections can mint a Connection from it). -UPDATE "mcp_source" -SET "config" = "config" - 'headers' - 'queryParams' -WHERE "config" IS NOT NULL;--> statement-breakpoint - -UPDATE "mcp_source" -SET "config" = "config" - 'auth' -WHERE "config" IS NOT NULL - AND ( - "config"#>>'{auth,kind}' = 'none' - OR ( - "config"#>>'{auth,kind}' = 'header' - AND "config"#>>'{auth,secretId}' IS NOT NULL - ) - OR ( - "config"#>>'{auth,kind}' = 'oauth2' - AND "config"#>>'{auth,connectionId}' IS NOT NULL - ) - ); diff --git a/apps/cloud/drizzle/0009_scoped_credentials_cutover.sql b/apps/cloud/drizzle/0009_scoped_credentials_cutover.sql deleted file mode 100644 index 4fa52d504..000000000 --- a/apps/cloud/drizzle/0009_scoped_credentials_cutover.sql +++ /dev/null @@ -1,1116 +0,0 @@ --- cloud scoped credential/source-slot/OAuth cutover. --- Squashes the PR-local migration chain into one runtime schema transition. --- 0009_add_credential_binding.sql -CREATE TABLE "credential_binding" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "plugin_id" text NOT NULL, - "source_id" text NOT NULL, - "source_scope_id" text NOT NULL, - "slot_key" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "secret_id" text, - "connection_id" text, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "credential_binding_scope_id_id_pk" PRIMARY KEY("scope_id","id") -); ---> statement-breakpoint -ALTER TABLE "graphql_source" ALTER COLUMN "auth_kind" SET DEFAULT 'none';--> statement-breakpoint -ALTER TABLE "openapi_source_binding" ALTER COLUMN "kind" DROP DEFAULT;--> statement-breakpoint -CREATE INDEX "credential_binding_scope_id_idx" ON "credential_binding" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "credential_binding_plugin_id_idx" ON "credential_binding" USING btree ("plugin_id");--> statement-breakpoint -CREATE INDEX "credential_binding_source_id_idx" ON "credential_binding" USING btree ("source_id");--> statement-breakpoint -CREATE INDEX "credential_binding_source_scope_id_idx" ON "credential_binding" USING btree ("source_scope_id");--> statement-breakpoint -CREATE INDEX "credential_binding_slot_key_idx" ON "credential_binding" USING btree ("slot_key");--> statement-breakpoint -CREATE INDEX "credential_binding_kind_idx" ON "credential_binding" USING btree ("kind");--> statement-breakpoint -CREATE INDEX "credential_binding_secret_id_idx" ON "credential_binding" USING btree ("secret_id");--> statement-breakpoint -CREATE INDEX "credential_binding_connection_id_idx" ON "credential_binding" USING btree ("connection_id");--> statement-breakpoint - -CREATE FUNCTION pg_temp.executor_credential_binding_id(plugin_id text, source_scope_id text, source_id text, slot_key text) -RETURNS text -LANGUAGE sql -IMMUTABLE -AS $$ - SELECT '[' || to_jsonb($1)::text || ',' || to_jsonb($2)::text || ',' || to_jsonb($3)::text || ',' || to_jsonb($4)::text || ']' -$$;--> statement-breakpoint - --- 0010_migrate_openapi_source_bindings.sql -INSERT INTO "credential_binding" ( - "id", - "scope_id", - "plugin_id", - "source_id", - "source_scope_id", - "slot_key", - "kind", - "text_value", - "secret_id", - "connection_id", - "created_at", - "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', "source_scope_id", "source_id", "slot"), - "target_scope_id", - 'openapi', - "source_id", - "source_scope_id", - "slot", - "kind", - "text_value", - "secret_id", - "connection_id", - "created_at", - "updated_at" -FROM "openapi_source_binding" -ON CONFLICT ("scope_id", "id") DO UPDATE SET - "plugin_id" = excluded."plugin_id", - "source_id" = excluded."source_id", - "source_scope_id" = excluded."source_scope_id", - "slot_key" = excluded."slot_key", - "kind" = excluded."kind", - "text_value" = excluded."text_value", - "secret_id" = excluded."secret_id", - "connection_id" = excluded."connection_id", - "updated_at" = excluded."updated_at";--> statement-breakpoint -DROP TABLE "openapi_source_binding" CASCADE;--> statement-breakpoint - --- 0011_openapi_credential_slots.sql --- Convert OpenAPI's remaining direct credential references to source-owned --- slot structure plus shared core credential_binding rows. Runtime code only --- reads the final slot model; this migration is the one-shot bridge. - -CREATE TEMP TABLE "__openapi_header_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__openapi_header_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - s."scope_id", - s."id", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h.key), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "openapi_source" s, jsonb_each(s."headers") h -WHERE s."headers" IS NOT NULL - AND jsonb_typeof(h.value) = 'object' - AND NOT (h.value ? 'kind') - AND h.value ? 'secretId';--> statement-breakpoint - -DROP TABLE "__openapi_header_slot_preflight";--> statement-breakpoint - -WITH header_rows AS ( - SELECT - s."scope_id", - s."id" AS "source_id", - h.key AS "name", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h.key), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - h.value->>'secretId' AS "secret_id" - FROM "openapi_source" s, jsonb_each(s."headers") h - WHERE s."headers" IS NOT NULL - AND jsonb_typeof(h.value) = 'object' - AND NOT (h.value ? 'kind') - AND h.value ? 'secretId' -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM header_rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."slot_key" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -UPDATE "openapi_source" s -SET "headers" = ( - SELECT jsonb_object_agg( - h.key, - CASE - WHEN jsonb_typeof(h.value) = 'object' - AND NOT (h.value ? 'kind') - AND h.value ? 'secretId' - THEN jsonb_strip_nulls(jsonb_build_object( - 'kind', 'binding', - 'slot', 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h.key), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - 'prefix', h.value->>'prefix' - )) - ELSE h.value - END - ) - FROM jsonb_each(s."headers") h -) -WHERE s."headers" IS NOT NULL;--> statement-breakpoint - -WITH oauth_rows AS ( - SELECT - s."scope_id", - s."id" AS "source_id", - 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':client-id' AS "client_id_slot", - s."oauth2"->>'clientIdSecretId' AS "client_id_secret_id" - FROM "openapi_source" s - WHERE s."oauth2" IS NOT NULL - AND s."oauth2"->>'connectionId' IS NOT NULL - AND s."oauth2"->>'clientIdSecretId' IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."client_id_slot"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."client_id_slot", - 'secret', - NULL, - r."client_id_secret_id", - NULL, - now(), - now() -FROM oauth_rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."client_id_slot" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -WITH oauth_rows AS ( - SELECT - s."scope_id", - s."id" AS "source_id", - 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':client-secret' AS "client_secret_slot", - s."oauth2"->>'clientSecretSecretId' AS "client_secret_secret_id" - FROM "openapi_source" s - WHERE s."oauth2" IS NOT NULL - AND s."oauth2"->>'connectionId' IS NOT NULL - AND s."oauth2"->>'clientSecretSecretId' IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."client_secret_slot"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."client_secret_slot", - 'secret', - NULL, - r."client_secret_secret_id", - NULL, - now(), - now() -FROM oauth_rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."client_secret_slot" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -WITH oauth_rows AS ( - SELECT - s."scope_id", - s."id" AS "source_id", - 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':connection' AS "connection_slot", - s."oauth2"->>'connectionId' AS "connection_id" - FROM "openapi_source" s - WHERE s."oauth2" IS NOT NULL - AND s."oauth2"->>'connectionId' IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."connection_slot"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."connection_slot", - 'connection', - NULL, - NULL, - r."connection_id", - now(), - now() -FROM oauth_rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."connection_slot" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -UPDATE "openapi_source" s -SET "oauth2" = jsonb_build_object( - 'kind', 'oauth2', - 'securitySchemeName', s."oauth2"->>'securitySchemeName', - 'flow', s."oauth2"->>'flow', - 'tokenUrl', s."oauth2"->>'tokenUrl', - 'authorizationUrl', s."oauth2"->'authorizationUrl', - 'issuerUrl', s."oauth2"->'issuerUrl', - 'clientIdSlot', 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':client-id', - 'clientSecretSlot', - CASE - WHEN s."oauth2"->>'clientSecretSecretId' IS NULL THEN NULL - ELSE 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':client-secret' - END, - 'connectionSlot', 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':connection', - 'scopes', COALESCE(s."oauth2"->'scopes', '[]'::jsonb) -) -WHERE s."oauth2" IS NOT NULL - AND s."oauth2"->>'connectionId' IS NOT NULL;--> statement-breakpoint - -CREATE TEMP TABLE "__openapi_query_param_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__openapi_query_param_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - r."scope_id", - r."source_id", - 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(r."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "openapi_source_query_param" r -WHERE r."kind" = 'secret' AND r."secret_id" IS NOT NULL;--> statement-breakpoint - -DROP TABLE "__openapi_query_param_slot_preflight";--> statement-breakpoint - -WITH rows AS ( - SELECT - r."scope_id", - r."source_id", - 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(r."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - r."secret_id" - FROM "openapi_source_query_param" r - WHERE r."kind" = 'secret' AND r."secret_id" IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."slot_key" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -ALTER TABLE "openapi_source_query_param" ADD COLUMN "slot_key" text;--> statement-breakpoint -ALTER TABLE "openapi_source_query_param" ADD COLUMN "prefix" text;--> statement-breakpoint -UPDATE "openapi_source_query_param" -SET - "slot_key" = 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim("name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - "prefix" = "secret_prefix", - "kind" = 'binding' -WHERE "kind" = 'secret';--> statement-breakpoint -DROP INDEX IF EXISTS "openapi_source_query_param_secret_id_idx";--> statement-breakpoint -ALTER TABLE "openapi_source_query_param" DROP COLUMN "secret_id";--> statement-breakpoint -ALTER TABLE "openapi_source_query_param" DROP COLUMN "secret_prefix";--> statement-breakpoint - -CREATE TEMP TABLE "__openapi_spec_fetch_header_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__openapi_spec_fetch_header_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - r."scope_id", - r."source_id", - 'spec_fetch_header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(r."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "openapi_source_spec_fetch_header" r -WHERE r."kind" = 'secret' AND r."secret_id" IS NOT NULL;--> statement-breakpoint - -DROP TABLE "__openapi_spec_fetch_header_slot_preflight";--> statement-breakpoint - -WITH rows AS ( - SELECT - r."scope_id", - r."source_id", - 'spec_fetch_header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(r."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - r."secret_id" - FROM "openapi_source_spec_fetch_header" r - WHERE r."kind" = 'secret' AND r."secret_id" IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."slot_key" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -ALTER TABLE "openapi_source_spec_fetch_header" ADD COLUMN "slot_key" text;--> statement-breakpoint -ALTER TABLE "openapi_source_spec_fetch_header" ADD COLUMN "prefix" text;--> statement-breakpoint -UPDATE "openapi_source_spec_fetch_header" -SET - "slot_key" = 'spec_fetch_header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim("name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - "prefix" = "secret_prefix", - "kind" = 'binding' -WHERE "kind" = 'secret';--> statement-breakpoint -DROP INDEX IF EXISTS "openapi_source_spec_fetch_header_secret_id_idx";--> statement-breakpoint -ALTER TABLE "openapi_source_spec_fetch_header" DROP COLUMN "secret_id";--> statement-breakpoint -ALTER TABLE "openapi_source_spec_fetch_header" DROP COLUMN "secret_prefix";--> statement-breakpoint - -CREATE TEMP TABLE "__openapi_spec_fetch_query_param_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__openapi_spec_fetch_query_param_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - r."scope_id", - r."source_id", - 'spec_fetch_query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(r."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "openapi_source_spec_fetch_query_param" r -WHERE r."kind" = 'secret' AND r."secret_id" IS NOT NULL;--> statement-breakpoint - -DROP TABLE "__openapi_spec_fetch_query_param_slot_preflight";--> statement-breakpoint - -WITH rows AS ( - SELECT - r."scope_id", - r."source_id", - 'spec_fetch_query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(r."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - r."secret_id" - FROM "openapi_source_spec_fetch_query_param" r - WHERE r."kind" = 'secret' AND r."secret_id" IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."slot_key" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -ALTER TABLE "openapi_source_spec_fetch_query_param" ADD COLUMN "slot_key" text;--> statement-breakpoint -ALTER TABLE "openapi_source_spec_fetch_query_param" ADD COLUMN "prefix" text;--> statement-breakpoint -UPDATE "openapi_source_spec_fetch_query_param" -SET - "slot_key" = 'spec_fetch_query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim("name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - "prefix" = "secret_prefix", - "kind" = 'binding' -WHERE "kind" = 'secret';--> statement-breakpoint -DROP INDEX IF EXISTS "openapi_source_spec_fetch_query_param_secret_id_idx";--> statement-breakpoint -ALTER TABLE "openapi_source_spec_fetch_query_param" DROP COLUMN "secret_id";--> statement-breakpoint -ALTER TABLE "openapi_source_spec_fetch_query_param" DROP COLUMN "secret_prefix";--> statement-breakpoint - --- 0012_graphql_credential_slots.sql --- Convert GraphQL's direct credential references to source-owned slot --- structure plus shared core credential_binding rows. Runtime code only --- reads the final slot model; this migration is the one-shot bridge. - -DROP INDEX "graphql_source_auth_connection_id_idx";--> statement-breakpoint -DROP INDEX "graphql_source_header_secret_id_idx";--> statement-breakpoint -DROP INDEX "graphql_source_query_param_secret_id_idx";--> statement-breakpoint - -ALTER TABLE "graphql_source" ADD COLUMN "auth_connection_slot" text;--> statement-breakpoint - -UPDATE "graphql_source" -SET "auth_connection_slot" = 'auth:oauth2:connection' -WHERE "auth_kind" = 'oauth2' - AND "auth_connection_id" IS NOT NULL;--> statement-breakpoint - -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('graphql', s."scope_id", s."id", 'auth:oauth2:connection'), - s."scope_id", - 'graphql', - s."id", - s."scope_id", - 'auth:oauth2:connection', - 'connection', - NULL, - NULL, - s."auth_connection_id", - now(), - now() -FROM "graphql_source" s -WHERE s."auth_kind" = 'oauth2' - AND s."auth_connection_id" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -ALTER TABLE "graphql_source" DROP COLUMN "auth_connection_id";--> statement-breakpoint - -ALTER TABLE "graphql_source_header" ADD COLUMN "slot_key" text;--> statement-breakpoint -ALTER TABLE "graphql_source_header" ADD COLUMN "prefix" text;--> statement-breakpoint - -CREATE TEMP TABLE "__graphql_header_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__graphql_header_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - h."scope_id", - h."source_id", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "graphql_source_header" h -WHERE h."kind" = 'secret' - AND h."secret_id" IS NOT NULL;--> statement-breakpoint - -DROP TABLE "__graphql_header_slot_preflight";--> statement-breakpoint - -WITH header_rows AS ( - SELECT - h."scope_id", - h."source_id", - h."name", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - h."secret_id", - h."secret_prefix" - FROM "graphql_source_header" h - WHERE h."kind" = 'secret' - AND h."secret_id" IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('graphql', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'graphql', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM header_rows r -ON CONFLICT DO NOTHING;--> statement-breakpoint - -UPDATE "graphql_source_header" -SET - "kind" = 'binding', - "slot_key" = 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim("name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - "prefix" = "secret_prefix", - "text_value" = NULL -WHERE "kind" = 'secret' - AND "secret_id" IS NOT NULL;--> statement-breakpoint - -ALTER TABLE "graphql_source_header" DROP COLUMN "secret_id";--> statement-breakpoint -ALTER TABLE "graphql_source_header" DROP COLUMN "secret_prefix";--> statement-breakpoint - -ALTER TABLE "graphql_source_query_param" ADD COLUMN "slot_key" text;--> statement-breakpoint -ALTER TABLE "graphql_source_query_param" ADD COLUMN "prefix" text;--> statement-breakpoint - -CREATE TEMP TABLE "__graphql_query_param_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__graphql_query_param_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - q."scope_id", - q."source_id", - 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(q."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "graphql_source_query_param" q -WHERE q."kind" = 'secret' - AND q."secret_id" IS NOT NULL;--> statement-breakpoint - -DROP TABLE "__graphql_query_param_slot_preflight";--> statement-breakpoint - -WITH query_param_rows AS ( - SELECT - q."scope_id", - q."source_id", - q."name", - 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(q."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - q."secret_id", - q."secret_prefix" - FROM "graphql_source_query_param" q - WHERE q."kind" = 'secret' - AND q."secret_id" IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('graphql', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'graphql', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM query_param_rows r -ON CONFLICT DO NOTHING;--> statement-breakpoint - -UPDATE "graphql_source_query_param" -SET - "kind" = 'binding', - "slot_key" = 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim("name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - "prefix" = "secret_prefix", - "text_value" = NULL -WHERE "kind" = 'secret' - AND "secret_id" IS NOT NULL;--> statement-breakpoint - -ALTER TABLE "graphql_source_query_param" DROP COLUMN "secret_id";--> statement-breakpoint -ALTER TABLE "graphql_source_query_param" DROP COLUMN "secret_prefix";--> statement-breakpoint - --- 0013_mcp_credential_slots.sql --- Convert MCP direct credential references to source-owned slot structure --- plus shared core credential_binding rows. Runtime code only reads the --- final slot model; this migration is the one-shot bridge from old data. - -DROP INDEX "mcp_source_auth_secret_id_idx";--> statement-breakpoint -DROP INDEX "mcp_source_auth_connection_id_idx";--> statement-breakpoint -DROP INDEX "mcp_source_auth_client_id_secret_id_idx";--> statement-breakpoint -DROP INDEX "mcp_source_auth_client_secret_secret_id_idx";--> statement-breakpoint -DROP INDEX "mcp_source_header_secret_id_idx";--> statement-breakpoint -DROP INDEX "mcp_source_query_param_secret_id_idx";--> statement-breakpoint - -ALTER TABLE "mcp_source" ADD COLUMN "auth_header_slot" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_header_prefix" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_connection_slot" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_client_id_slot" text;--> statement-breakpoint -ALTER TABLE "mcp_source" ADD COLUMN "auth_client_secret_slot" text;--> statement-breakpoint - -UPDATE "mcp_source" -SET - "auth_header_slot" = 'auth:header', - "auth_header_prefix" = "auth_secret_prefix" -WHERE "auth_kind" = 'header' - AND "auth_secret_id" IS NOT NULL;--> statement-breakpoint - -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('mcp', s."scope_id", s."id", 'auth:header'), - s."scope_id", - 'mcp', - s."id", - s."scope_id", - 'auth:header', - 'secret', - NULL, - s."auth_secret_id", - NULL, - now(), - now() -FROM "mcp_source" s -WHERE s."auth_kind" = 'header' - AND s."auth_secret_id" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -UPDATE "mcp_source" -SET - "auth_connection_slot" = 'auth:oauth2:connection', - "auth_client_id_slot" = CASE - WHEN "auth_client_id_secret_id" IS NOT NULL THEN 'auth:oauth2:client-id' - ELSE NULL - END, - "auth_client_secret_slot" = CASE - WHEN "auth_client_secret_secret_id" IS NOT NULL THEN 'auth:oauth2:client-secret' - ELSE NULL - END -WHERE "auth_kind" = 'oauth2' - AND "auth_connection_id" IS NOT NULL;--> statement-breakpoint - -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('mcp', s."scope_id", s."id", 'auth:oauth2:connection'), - s."scope_id", - 'mcp', - s."id", - s."scope_id", - 'auth:oauth2:connection', - 'connection', - NULL, - NULL, - s."auth_connection_id", - now(), - now() -FROM "mcp_source" s -WHERE s."auth_kind" = 'oauth2' - AND s."auth_connection_id" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('mcp', s."scope_id", s."id", 'auth:oauth2:client-id'), - s."scope_id", - 'mcp', - s."id", - s."scope_id", - 'auth:oauth2:client-id', - 'secret', - NULL, - s."auth_client_id_secret_id", - NULL, - now(), - now() -FROM "mcp_source" s -WHERE s."auth_kind" = 'oauth2' - AND s."auth_client_id_secret_id" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('mcp', s."scope_id", s."id", 'auth:oauth2:client-secret'), - s."scope_id", - 'mcp', - s."id", - s."scope_id", - 'auth:oauth2:client-secret', - 'secret', - NULL, - s."auth_client_secret_secret_id", - NULL, - now(), - now() -FROM "mcp_source" s -WHERE s."auth_kind" = 'oauth2' - AND s."auth_client_secret_secret_id" IS NOT NULL -ON CONFLICT DO NOTHING;--> statement-breakpoint - -ALTER TABLE "mcp_source" DROP COLUMN "auth_secret_id";--> statement-breakpoint -ALTER TABLE "mcp_source" DROP COLUMN "auth_secret_prefix";--> statement-breakpoint -ALTER TABLE "mcp_source" DROP COLUMN "auth_connection_id";--> statement-breakpoint -ALTER TABLE "mcp_source" DROP COLUMN "auth_client_id_secret_id";--> statement-breakpoint -ALTER TABLE "mcp_source" DROP COLUMN "auth_client_secret_secret_id";--> statement-breakpoint - -ALTER TABLE "mcp_source_header" ADD COLUMN "slot_key" text;--> statement-breakpoint -ALTER TABLE "mcp_source_header" ADD COLUMN "prefix" text;--> statement-breakpoint - -CREATE TEMP TABLE "__mcp_header_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__mcp_header_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - h."scope_id", - h."source_id", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "mcp_source_header" h -WHERE h."kind" = 'secret' - AND h."secret_id" IS NOT NULL;--> statement-breakpoint - -DROP TABLE "__mcp_header_slot_preflight";--> statement-breakpoint - -WITH header_rows AS ( - SELECT - h."scope_id", - h."source_id", - h."name", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - h."secret_id", - h."secret_prefix" - FROM "mcp_source_header" h - WHERE h."kind" = 'secret' - AND h."secret_id" IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('mcp', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'mcp', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM header_rows r -ON CONFLICT DO NOTHING;--> statement-breakpoint - -UPDATE "mcp_source_header" -SET - "kind" = 'binding', - "slot_key" = 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim("name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - "prefix" = "secret_prefix", - "text_value" = NULL -WHERE "kind" = 'secret' - AND "secret_id" IS NOT NULL;--> statement-breakpoint - -ALTER TABLE "mcp_source_header" DROP COLUMN "secret_id";--> statement-breakpoint -ALTER TABLE "mcp_source_header" DROP COLUMN "secret_prefix";--> statement-breakpoint - -ALTER TABLE "mcp_source_query_param" ADD COLUMN "slot_key" text;--> statement-breakpoint -ALTER TABLE "mcp_source_query_param" ADD COLUMN "prefix" text;--> statement-breakpoint - -CREATE TEMP TABLE "__mcp_query_param_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__mcp_query_param_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - q."scope_id", - q."source_id", - 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(q."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "mcp_source_query_param" q -WHERE q."kind" = 'secret' - AND q."secret_id" IS NOT NULL;--> statement-breakpoint - -DROP TABLE "__mcp_query_param_slot_preflight";--> statement-breakpoint - -WITH query_param_rows AS ( - SELECT - q."scope_id", - q."source_id", - q."name", - 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(q."name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - q."secret_id", - q."secret_prefix" - FROM "mcp_source_query_param" q - WHERE q."kind" = 'secret' - AND q."secret_id" IS NOT NULL -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('mcp', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'mcp', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM query_param_rows r -ON CONFLICT DO NOTHING;--> statement-breakpoint - -UPDATE "mcp_source_query_param" -SET - "kind" = 'binding', - "slot_key" = 'query_param:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim("name"), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default'), - "prefix" = "secret_prefix", - "text_value" = NULL -WHERE "kind" = 'secret' - AND "secret_id" IS NOT NULL;--> statement-breakpoint - -ALTER TABLE "mcp_source_query_param" DROP COLUMN "secret_id";--> statement-breakpoint -ALTER TABLE "mcp_source_query_param" DROP COLUMN "secret_prefix";--> statement-breakpoint - --- 0014_normalize_oauth_connections.sql --- Normalize pre-unified OAuth connection rows to the canonical core --- provider/provider_state shape. Runtime refresh only reads provider='oauth2' --- and provider_state.kind after this one-shot data migration. - -UPDATE "connection" -SET - "provider" = 'oauth2', - "provider_state" = jsonb_build_object( - 'kind', CASE "provider_state"->>'flow' - WHEN 'authorizationCode' THEN 'authorization-code' - ELSE 'client-credentials' - END, - 'tokenEndpoint', "provider_state"->>'tokenUrl', - 'issuerUrl', NULL, - 'clientIdSecretId', "provider_state"->>'clientIdSecretId', - 'clientSecretSecretId', CASE "provider_state"->>'flow' - WHEN 'clientCredentials' THEN coalesce("provider_state"->>'clientSecretSecretId', '') - ELSE "provider_state"->>'clientSecretSecretId' - END, - 'clientAuth', 'body', - 'scopes', coalesce("provider_state"->'scopes', '[]'::jsonb), - 'scope', "scope" - ), - "updated_at" = now() -WHERE "provider" = 'openapi:oauth2' - AND "provider_state"->>'flow' IN ('authorizationCode', 'clientCredentials');--> statement-breakpoint - -UPDATE "connection" -SET - "provider" = 'oauth2', - "provider_state" = jsonb_build_object( - 'kind', 'dynamic-dcr', - 'tokenEndpoint', coalesce( - "provider_state"->>'tokenEndpoint', - "provider_state"#>>'{authorizationServerMetadata,token_endpoint}', - '' - ), - 'issuerUrl', "provider_state"#>>'{authorizationServerMetadata,issuer}', - 'authorizationServerUrl', "provider_state"->>'authorizationServerUrl', - 'authorizationServerMetadataUrl', "provider_state"->>'authorizationServerMetadataUrl', - 'idTokenSigningAlgValuesSupported', coalesce( - "provider_state"#>'{authorizationServerMetadata,id_token_signing_alg_values_supported}', - '[]'::jsonb - ), - 'clientId', coalesce("provider_state"#>>'{clientInformation,client_id}', ''), - 'clientSecretSecretId', NULL, - 'clientAuth', CASE "provider_state"#>>'{clientInformation,token_endpoint_auth_method}' - WHEN 'client_secret_basic' THEN 'basic' - ELSE 'body' - END, - 'scopes', '[]'::jsonb, - 'scope', "scope", - 'resource', "provider_state"->>'endpoint' - ), - "updated_at" = now() -WHERE "provider" = 'mcp:oauth2';--> statement-breakpoint - --- 0015_openapi_header_rows.sql --- Move OpenAPI request headers out of openapi_source.headers JSON and into --- the same child-row slot model used by query params and spec-fetch --- credentials. Runtime code reads only openapi_source_header after this. - -CREATE TABLE "openapi_source_header" ( - "id" text NOT NULL, - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "name" text NOT NULL, - "kind" text NOT NULL, - "text_value" text, - "slot_key" text, - "prefix" text, - CONSTRAINT "openapi_source_header_scope_id_id_pk" PRIMARY KEY("scope_id","id") -);--> statement-breakpoint -CREATE INDEX "openapi_source_header_scope_id_idx" ON "openapi_source_header" USING btree ("scope_id");--> statement-breakpoint -CREATE INDEX "openapi_source_header_source_id_idx" ON "openapi_source_header" USING btree ("source_id");--> statement-breakpoint - -CREATE TEMP TABLE "__openapi_header_row_slot_preflight" ( - "scope_id" text NOT NULL, - "source_id" text NOT NULL, - "slot_key" text NOT NULL, - PRIMARY KEY ("scope_id", "source_id", "slot_key") -) ON COMMIT DROP;--> statement-breakpoint - -INSERT INTO "__openapi_header_row_slot_preflight" ("scope_id", "source_id", "slot_key") -SELECT - s."scope_id", - s."id", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h.key), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key" -FROM "openapi_source" s, jsonb_each(s."headers") h -WHERE s."headers" IS NOT NULL - AND jsonb_typeof(h.value) = 'object' - AND h.value ? 'secretId' - AND COALESCE(h.value->>'kind', 'secret') = 'secret';--> statement-breakpoint - -DROP TABLE "__openapi_header_row_slot_preflight";--> statement-breakpoint - -WITH header_rows AS ( - SELECT - s."scope_id", - s."id" AS "source_id", - h.key AS "name", - 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h.key), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') AS "slot_key", - h.value->>'secretId' AS "secret_id" - FROM "openapi_source" s, jsonb_each(s."headers") h - WHERE s."headers" IS NOT NULL - AND jsonb_typeof(h.value) = 'object' - AND h.value ? 'secretId' - AND COALESCE(h.value->>'kind', 'secret') = 'secret' -) -INSERT INTO "credential_binding" ( - "id", "scope_id", "plugin_id", "source_id", "source_scope_id", "slot_key", - "kind", "text_value", "secret_id", "connection_id", "created_at", "updated_at" -) -SELECT - pg_temp.executor_credential_binding_id('openapi', r."scope_id", r."source_id", r."slot_key"), - r."scope_id", - 'openapi', - r."source_id", - r."scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - now(), - now() -FROM header_rows r -WHERE NOT EXISTS ( - SELECT 1 FROM "credential_binding" b - WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = 'openapi' - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."scope_id" - AND b."slot_key" = r."slot_key" -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -INSERT INTO "openapi_source_header" ( - "id", "scope_id", "source_id", "name", "kind", "text_value", "slot_key", "prefix" -) -SELECT - jsonb_build_array(s."id", h.key)::text, - s."scope_id", - s."id", - h.key, - CASE - WHEN jsonb_typeof(h.value) = 'string' THEN 'text' - WHEN jsonb_typeof(h.value) = 'object' AND h.value->>'kind' = 'text' THEN 'text' - ELSE 'binding' - END, - CASE - WHEN jsonb_typeof(h.value) = 'string' THEN h.value #>> '{}' - WHEN jsonb_typeof(h.value) = 'object' AND h.value->>'kind' = 'text' - THEN h.value->>'text' - ELSE NULL - END, - CASE - WHEN jsonb_typeof(h.value) = 'object' AND h.value->>'kind' = 'binding' - THEN h.value->>'slot' - WHEN jsonb_typeof(h.value) = 'object' AND h.value ? 'secretId' - THEN 'header:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(h.key), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') - ELSE NULL - END, - CASE - WHEN jsonb_typeof(h.value) = 'object' - THEN COALESCE(h.value->>'prefix', h.value->>'secretPrefix') - ELSE NULL - END -FROM "openapi_source" s, jsonb_each(s."headers") h -WHERE s."headers" IS NOT NULL - AND ( - jsonb_typeof(h.value) = 'string' - OR ( - jsonb_typeof(h.value) = 'object' - AND ( - h.value->>'kind' IN ('binding', 'text') - OR h.value ? 'secretId' - ) - ) -) -ON CONFLICT DO NOTHING;--> statement-breakpoint - -ALTER TABLE "openapi_source" DROP COLUMN "headers"; diff --git a/apps/cloud/drizzle/0010_repair_mcp_connection_binding_scopes.sql b/apps/cloud/drizzle/0010_repair_mcp_connection_binding_scopes.sql deleted file mode 100644 index d75bb7192..000000000 --- a/apps/cloud/drizzle/0010_repair_mcp_connection_binding_scopes.sql +++ /dev/null @@ -1,70 +0,0 @@ --- Repair MCP OAuth connection bindings created by the scoped credential cutover. --- --- The cutover copied legacy mcp_source.auth_connection_id rows into --- credential_binding at the source owner scope. For shared org MCP sources, --- the referenced Connection rows are user-owned and live at user-org scope, so --- those migrated bindings cannot resolve. Copy each invalid binding to every --- matching user-org scope under the same org, then remove invalid rows that --- still point at a scope without the referenced Connection. - -INSERT INTO "credential_binding" ( - "id", - "scope_id", - "plugin_id", - "source_id", - "source_scope_id", - "slot_key", - "kind", - "text_value", - "secret_id", - "connection_id", - "created_at", - "updated_at" -) -SELECT - b."id", - c."scope_id", - b."plugin_id", - b."source_id", - b."source_scope_id", - b."slot_key", - b."kind", - b."text_value", - b."secret_id", - b."connection_id", - b."created_at", - now() -FROM "credential_binding" b -JOIN "connection" c - ON c."id" = b."connection_id" - AND c."scope_id" LIKE 'user-org:%:' || b."source_scope_id" -WHERE b."plugin_id" = 'mcp' - AND b."kind" = 'connection' - AND b."slot_key" = 'auth:oauth2:connection' - AND NOT EXISTS ( - SELECT 1 - FROM "connection" exact - WHERE exact."id" = b."connection_id" - AND exact."scope_id" = b."scope_id" - ) -ON CONFLICT ("scope_id", "id") DO UPDATE SET - "plugin_id" = excluded."plugin_id", - "source_id" = excluded."source_id", - "source_scope_id" = excluded."source_scope_id", - "slot_key" = excluded."slot_key", - "kind" = excluded."kind", - "text_value" = excluded."text_value", - "secret_id" = excluded."secret_id", - "connection_id" = excluded."connection_id", - "updated_at" = excluded."updated_at";--> statement-breakpoint - -DELETE FROM "credential_binding" b -WHERE b."plugin_id" = 'mcp' - AND b."kind" = 'connection' - AND b."slot_key" = 'auth:oauth2:connection' - AND NOT EXISTS ( - SELECT 1 - FROM "connection" c - WHERE c."id" = b."connection_id" - AND c."scope_id" = b."scope_id" - );--> statement-breakpoint diff --git a/apps/cloud/drizzle/0011_repair_openapi_connection_binding_scopes.sql b/apps/cloud/drizzle/0011_repair_openapi_connection_binding_scopes.sql deleted file mode 100644 index ee3ba7188..000000000 --- a/apps/cloud/drizzle/0011_repair_openapi_connection_binding_scopes.sql +++ /dev/null @@ -1,68 +0,0 @@ --- Repair OpenAPI OAuth connection bindings that still point at scopes without --- the referenced Connection row. --- --- Shared org sources can use user-owned OAuth connections. If a migrated --- binding landed at org scope while the Connection lives at user-org scope, --- copy it to the matching user-org scope under the same org. Any remaining --- invalid connection binding has no backing Connection and should be removed --- so the source falls back to sign-in. - -INSERT INTO "credential_binding" ( - "id", - "scope_id", - "plugin_id", - "source_id", - "source_scope_id", - "slot_key", - "kind", - "text_value", - "secret_id", - "connection_id", - "created_at", - "updated_at" -) -SELECT - b."id", - c."scope_id", - b."plugin_id", - b."source_id", - b."source_scope_id", - b."slot_key", - b."kind", - b."text_value", - b."secret_id", - b."connection_id", - b."created_at", - now() -FROM "credential_binding" b -JOIN "connection" c - ON c."id" = b."connection_id" - AND c."scope_id" LIKE 'user-org:%:' || b."source_scope_id" -WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'connection' - AND NOT EXISTS ( - SELECT 1 - FROM "connection" exact - WHERE exact."id" = b."connection_id" - AND exact."scope_id" = b."scope_id" - ) -ON CONFLICT ("scope_id", "id") DO UPDATE SET - "plugin_id" = excluded."plugin_id", - "source_id" = excluded."source_id", - "source_scope_id" = excluded."source_scope_id", - "slot_key" = excluded."slot_key", - "kind" = excluded."kind", - "text_value" = excluded."text_value", - "secret_id" = excluded."secret_id", - "connection_id" = excluded."connection_id", - "updated_at" = excluded."updated_at";--> statement-breakpoint - -DELETE FROM "credential_binding" b -WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'connection' - AND NOT EXISTS ( - SELECT 1 - FROM "connection" c - WHERE c."id" = b."connection_id" - AND c."scope_id" = b."scope_id" - );--> statement-breakpoint diff --git a/apps/cloud/drizzle/0012_repair_openapi_secret_binding_scopes.sql b/apps/cloud/drizzle/0012_repair_openapi_secret_binding_scopes.sql deleted file mode 100644 index 02c59270e..000000000 --- a/apps/cloud/drizzle/0012_repair_openapi_secret_binding_scopes.sql +++ /dev/null @@ -1,73 +0,0 @@ --- Repair OpenAPI secret bindings that point at scopes without the referenced --- Secret row. --- --- Most affected rows are user-org bindings for shared org sources where the --- backing Secret lives at the source/org scope. A smaller case is the inverse: --- an org binding whose matching Secret is user-owned under the same org. Copy --- each invalid binding to the matching in-org Secret scope, then remove any --- invalid OpenAPI secret binding that still has no Secret at its own scope. --- This deliberately does not copy secrets across org boundaries. - -INSERT INTO "credential_binding" ( - "id", - "scope_id", - "plugin_id", - "source_id", - "source_scope_id", - "slot_key", - "kind", - "text_value", - "secret_id", - "connection_id", - "created_at", - "updated_at" -) -SELECT DISTINCT ON (s."scope_id", b."id") - b."id", - s."scope_id", - b."plugin_id", - b."source_id", - b."source_scope_id", - b."slot_key", - b."kind", - b."text_value", - b."secret_id", - b."connection_id", - b."created_at", - now() -FROM "credential_binding" b -JOIN "secret" s - ON s."id" = b."secret_id" - AND ( - s."scope_id" = b."source_scope_id" - OR s."scope_id" LIKE 'user-org:%:' || b."source_scope_id" - ) -WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'secret' - AND NOT EXISTS ( - SELECT 1 - FROM "secret" exact - WHERE exact."id" = b."secret_id" - AND exact."scope_id" = b."scope_id" - ) -ORDER BY s."scope_id", b."id", b."updated_at" DESC -ON CONFLICT ("scope_id", "id") DO UPDATE SET - "plugin_id" = excluded."plugin_id", - "source_id" = excluded."source_id", - "source_scope_id" = excluded."source_scope_id", - "slot_key" = excluded."slot_key", - "kind" = excluded."kind", - "text_value" = excluded."text_value", - "secret_id" = excluded."secret_id", - "connection_id" = excluded."connection_id", - "updated_at" = excluded."updated_at";--> statement-breakpoint - -DELETE FROM "credential_binding" b -WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'secret' - AND NOT EXISTS ( - SELECT 1 - FROM "secret" s - WHERE s."id" = b."secret_id" - AND s."scope_id" = b."scope_id" - );--> statement-breakpoint diff --git a/apps/cloud/drizzle/0013_cleanup_orphan_oauth_rows.sql b/apps/cloud/drizzle/0013_cleanup_orphan_oauth_rows.sql deleted file mode 100644 index c9cafd9dd..000000000 --- a/apps/cloud/drizzle/0013_cleanup_orphan_oauth_rows.sql +++ /dev/null @@ -1,23 +0,0 @@ --- Remove stale OAuth rows left behind after connection cleanup. --- --- Connection-owned Secret rows are hidden implementation details for a --- Connection. If the owning Connection row no longer exists at the same scope, --- the Secret row is unreachable and should not remain as dangling metadata. --- Likewise, OAuth sessions for missing Connections cannot complete safely. - -DELETE FROM "secret" s -WHERE s."owned_by_connection_id" IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM "connection" c - WHERE c."id" = s."owned_by_connection_id" - AND c."scope_id" = s."scope_id" - );--> statement-breakpoint - -DELETE FROM "oauth2_session" o -WHERE NOT EXISTS ( - SELECT 1 - FROM "connection" c - WHERE c."id" = o."connection_id" - AND c."scope_id" = o."scope_id" - );--> statement-breakpoint diff --git a/apps/cloud/drizzle/0014_repair_openapi_oauth_cutover_residue.sql b/apps/cloud/drizzle/0014_repair_openapi_oauth_cutover_residue.sql deleted file mode 100644 index b20b7a4fe..000000000 --- a/apps/cloud/drizzle/0014_repair_openapi_oauth_cutover_residue.sql +++ /dev/null @@ -1,241 +0,0 @@ --- Repair OpenAPI OAuth residue from the scoped-credential cutover. --- --- 0009 only normalized provider='openapi:oauth2' rows whose provider_state --- still had the old `flow` shape. Some live rows already had canonical --- `kind` provider_state, so the provider key was skipped. 0012 also collapsed --- user-scoped OpenAPI OAuth client credential bindings when the backing Secret --- row lived at the source/org scope. Runtime refresh reads provider_state --- directly, but edit/refresh UI reads credential_binding rows, so recreate --- those explicit bindings from the already-canonical Connection rows. - -UPDATE "connection" -SET "provider" = 'oauth2', - "updated_at" = now() -WHERE "provider" = 'openapi:oauth2' - AND "provider_state" ? 'kind';--> statement-breakpoint - -WITH oauth_connections AS ( - SELECT - b."scope_id", - b."id" AS "binding_id", - b."plugin_id", - b."source_id", - b."source_scope_id", - s."oauth2"->>'clientIdSlot' AS "slot_key", - c."provider_state"->>'clientIdSecretId' AS "secret_id", - b."created_at" - FROM "credential_binding" b - JOIN "openapi_source" s - ON s."id" = b."source_id" - AND s."scope_id" = b."source_scope_id" - JOIN "connection" c - ON c."id" = b."connection_id" - AND c."scope_id" = b."scope_id" - WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'connection' - AND s."oauth2" IS NOT NULL - AND c."provider" = 'oauth2' - AND c."provider_state" ? 'clientIdSecretId' - AND coalesce(c."provider_state"->>'clientIdSecretId', '') <> '' -) -UPDATE "credential_binding" b -SET "secret_id" = r."secret_id", - "text_value" = NULL, - "connection_id" = NULL, - "updated_at" = now() -FROM oauth_connections r -WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = r."plugin_id" - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."source_scope_id" - AND b."slot_key" = r."slot_key" - AND b."kind" = 'secret';--> statement-breakpoint - -WITH oauth_connections AS ( - SELECT - b."scope_id", - b."id" AS "binding_id", - b."plugin_id", - b."source_id", - b."source_scope_id", - s."oauth2"->>'clientIdSlot' AS "slot_key", - c."provider_state"->>'clientIdSecretId' AS "secret_id", - b."created_at" - FROM "credential_binding" b - JOIN "openapi_source" s - ON s."id" = b."source_id" - AND s."scope_id" = b."source_scope_id" - JOIN "connection" c - ON c."id" = b."connection_id" - AND c."scope_id" = b."scope_id" - WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'connection' - AND s."oauth2" IS NOT NULL - AND c."provider" = 'oauth2' - AND c."provider_state" ? 'clientIdSecretId' - AND coalesce(c."provider_state"->>'clientIdSecretId', '') <> '' -) -INSERT INTO "credential_binding" ( - "id", - "scope_id", - "plugin_id", - "source_id", - "source_scope_id", - "slot_key", - "kind", - "text_value", - "secret_id", - "connection_id", - "created_at", - "updated_at" -) -SELECT - '[' || to_jsonb('oconn-openapi-oauth-client'::text)::text || ',' || to_jsonb(r."binding_id")::text || ',' || to_jsonb(r."scope_id")::text || ',' || to_jsonb(r."slot_key")::text || ']', - r."scope_id", - r."plugin_id", - r."source_id", - r."source_scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - r."created_at", - now() -FROM oauth_connections r -WHERE r."slot_key" IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM "credential_binding" existing - WHERE existing."scope_id" = r."scope_id" - AND existing."plugin_id" = r."plugin_id" - AND existing."source_id" = r."source_id" - AND existing."source_scope_id" = r."source_scope_id" - AND existing."slot_key" = r."slot_key" - ) -ON CONFLICT ("scope_id", "id") DO UPDATE SET - "plugin_id" = excluded."plugin_id", - "source_id" = excluded."source_id", - "source_scope_id" = excluded."source_scope_id", - "slot_key" = excluded."slot_key", - "kind" = excluded."kind", - "text_value" = excluded."text_value", - "secret_id" = excluded."secret_id", - "connection_id" = excluded."connection_id", - "updated_at" = excluded."updated_at";--> statement-breakpoint - -WITH oauth_connections AS ( - SELECT - b."scope_id", - b."id" AS "binding_id", - b."plugin_id", - b."source_id", - b."source_scope_id", - coalesce( - s."oauth2"->>'clientSecretSlot', - 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':client-secret' - ) AS "slot_key", - c."provider_state"->>'clientSecretSecretId' AS "secret_id", - b."created_at" - FROM "credential_binding" b - JOIN "openapi_source" s - ON s."id" = b."source_id" - AND s."scope_id" = b."source_scope_id" - JOIN "connection" c - ON c."id" = b."connection_id" - AND c."scope_id" = b."scope_id" - WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'connection' - AND s."oauth2" IS NOT NULL - AND c."provider" = 'oauth2' - AND c."provider_state" ? 'clientSecretSecretId' - AND coalesce(c."provider_state"->>'clientSecretSecretId', '') <> '' -) -UPDATE "credential_binding" b -SET "secret_id" = r."secret_id", - "text_value" = NULL, - "connection_id" = NULL, - "updated_at" = now() -FROM oauth_connections r -WHERE b."scope_id" = r."scope_id" - AND b."plugin_id" = r."plugin_id" - AND b."source_id" = r."source_id" - AND b."source_scope_id" = r."source_scope_id" - AND b."slot_key" = r."slot_key" - AND b."kind" = 'secret';--> statement-breakpoint - -WITH oauth_connections AS ( - SELECT - b."scope_id", - b."id" AS "binding_id", - b."plugin_id", - b."source_id", - b."source_scope_id", - coalesce( - s."oauth2"->>'clientSecretSlot', - 'oauth2:' || COALESCE(NULLIF(trim(both '-' from lower(regexp_replace(trim(s."oauth2"->>'securitySchemeName'), '[^a-zA-Z0-9]+', '-', 'g'))), ''), 'default') || ':client-secret' - ) AS "slot_key", - c."provider_state"->>'clientSecretSecretId' AS "secret_id", - b."created_at" - FROM "credential_binding" b - JOIN "openapi_source" s - ON s."id" = b."source_id" - AND s."scope_id" = b."source_scope_id" - JOIN "connection" c - ON c."id" = b."connection_id" - AND c."scope_id" = b."scope_id" - WHERE b."plugin_id" = 'openapi' - AND b."kind" = 'connection' - AND s."oauth2" IS NOT NULL - AND c."provider" = 'oauth2' - AND c."provider_state" ? 'clientSecretSecretId' - AND coalesce(c."provider_state"->>'clientSecretSecretId', '') <> '' -) -INSERT INTO "credential_binding" ( - "id", - "scope_id", - "plugin_id", - "source_id", - "source_scope_id", - "slot_key", - "kind", - "text_value", - "secret_id", - "connection_id", - "created_at", - "updated_at" -) -SELECT - '[' || to_jsonb('oconn-openapi-oauth-secret'::text)::text || ',' || to_jsonb(r."binding_id")::text || ',' || to_jsonb(r."scope_id")::text || ',' || to_jsonb(r."slot_key")::text || ']', - r."scope_id", - r."plugin_id", - r."source_id", - r."source_scope_id", - r."slot_key", - 'secret', - NULL, - r."secret_id", - NULL, - r."created_at", - now() -FROM oauth_connections r -WHERE r."slot_key" IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM "credential_binding" existing - WHERE existing."scope_id" = r."scope_id" - AND existing."plugin_id" = r."plugin_id" - AND existing."source_id" = r."source_id" - AND existing."source_scope_id" = r."source_scope_id" - AND existing."slot_key" = r."slot_key" - ) -ON CONFLICT ("scope_id", "id") DO UPDATE SET - "plugin_id" = excluded."plugin_id", - "source_id" = excluded."source_id", - "source_scope_id" = excluded."source_scope_id", - "slot_key" = excluded."slot_key", - "kind" = excluded."kind", - "text_value" = excluded."text_value", - "secret_id" = excluded."secret_id", - "connection_id" = excluded."connection_id", - "updated_at" = excluded."updated_at"; diff --git a/apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql b/apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql deleted file mode 100644 index 90339ea0f..000000000 --- a/apps/cloud/drizzle/0015_add_credential_binding_secret_scope.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE "credential_binding" ADD COLUMN "secret_scope_id" text;--> statement-breakpoint -CREATE INDEX "credential_binding_secret_scope_id_idx" ON "credential_binding" USING btree ("secret_scope_id"); diff --git a/apps/cloud/drizzle/0016_fumadb_cutover.sql b/apps/cloud/drizzle/0016_fumadb_cutover.sql deleted file mode 100644 index ce2d089b0..000000000 --- a/apps/cloud/drizzle/0016_fumadb_cutover.sql +++ /dev/null @@ -1,114 +0,0 @@ -CREATE TABLE IF NOT EXISTS "private_executor_cloud_settings" ( - "id" varchar(255) PRIMARY KEY NOT NULL, - "version" varchar(255) DEFAULT '1.0.0' NOT NULL -); ---> statement-breakpoint -INSERT INTO "private_executor_cloud_settings" ("id", "version") -VALUES ('default', '1.0.0') -ON CONFLICT ("id") DO UPDATE SET "version" = excluded."version"; ---> statement-breakpoint -ALTER TABLE "credential_binding" ADD COLUMN IF NOT EXISTS "secret_scope_id" text; ---> statement-breakpoint -ALTER TABLE "blob" ADD COLUMN IF NOT EXISTS "row_id" varchar(255); ---> statement-breakpoint -ALTER TABLE "blob" ADD COLUMN IF NOT EXISTS "id" varchar(255); ---> statement-breakpoint -UPDATE "blob" -SET - "id" = COALESCE("id", '[' || to_json("namespace")::text || ',' || to_json("key")::text || ']'), - "row_id" = COALESCE("row_id", 'legacy_' || md5("namespace" || chr(31) || "key")) -WHERE "id" IS NULL OR "row_id" IS NULL; ---> statement-breakpoint -ALTER TABLE "blob" ALTER COLUMN "id" SET NOT NULL; ---> statement-breakpoint -ALTER TABLE "blob" ALTER COLUMN "row_id" SET NOT NULL; ---> statement-breakpoint -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conrelid = 'public.blob'::regclass - AND conname = 'blob_namespace_key_pk' - ) THEN - ALTER TABLE "blob" DROP CONSTRAINT "blob_namespace_key_pk"; - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conrelid = 'public.blob'::regclass - AND conname = 'blob_pkey' - ) THEN - ALTER TABLE "blob" ADD CONSTRAINT "blob_pkey" PRIMARY KEY ("row_id"); - END IF; -END $$; ---> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "blob_id_uidx" ON "blob" USING btree ("id"); ---> statement-breakpoint -DO $$ -DECLARE - table_name text; - legacy_pk_name text; - new_pk_name text; - new_unique_name text; -BEGIN - FOREACH table_name IN ARRAY ARRAY[ - 'connection', - 'credential_binding', - 'definition', - 'graphql_operation', - 'graphql_source', - 'graphql_source_header', - 'graphql_source_query_param', - 'mcp_binding', - 'mcp_source', - 'mcp_source_header', - 'mcp_source_query_param', - 'oauth2_session', - 'openapi_operation', - 'openapi_source', - 'openapi_source_header', - 'openapi_source_query_param', - 'openapi_source_spec_fetch_header', - 'openapi_source_spec_fetch_query_param', - 'secret', - 'source', - 'tool', - 'tool_policy', - 'workos_vault_metadata' - ] - LOOP - legacy_pk_name := table_name || '_scope_id_id_pk'; - new_pk_name := table_name || '_pkey'; - new_unique_name := table_name || '_scope_id_id_uidx'; - - EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS "row_id" varchar(255)', table_name); - EXECUTE format( - 'UPDATE %I SET "row_id" = COALESCE("row_id", %L || md5("scope_id" || chr(31) || "id")) WHERE "row_id" IS NULL', - table_name, - 'legacy_' - ); - EXECUTE format('ALTER TABLE %I ALTER COLUMN "row_id" SET NOT NULL', table_name); - - IF EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conrelid = format('public.%I', table_name)::regclass - AND conname = legacy_pk_name - ) THEN - EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', table_name, legacy_pk_name); - END IF; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conrelid = format('public.%I', table_name)::regclass - AND conname = new_pk_name - ) THEN - EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I PRIMARY KEY ("row_id")', table_name, new_pk_name); - END IF; - - EXECUTE format( - 'CREATE UNIQUE INDEX IF NOT EXISTS %I ON %I USING btree ("scope_id", "id")', - new_unique_name, - table_name - ); - END LOOP; -END $$; diff --git a/apps/cloud/drizzle/0017_plugin_storage_sources.sql b/apps/cloud/drizzle/0017_plugin_storage_sources.sql deleted file mode 100644 index a0ef902b1..000000000 --- a/apps/cloud/drizzle/0017_plugin_storage_sources.sql +++ /dev/null @@ -1,172 +0,0 @@ -CREATE TABLE IF NOT EXISTS "plugin_storage" ( - "row_id" varchar(255) PRIMARY KEY NOT NULL, - "id" varchar(255) NOT NULL, - "scope_id" varchar(255) NOT NULL, - "plugin_id" text NOT NULL, - "collection" text NOT NULL, - "key" text NOT NULL, - "data" json NOT NULL, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX IF NOT EXISTS "plugin_storage_scope_id_id_uidx" ON "plugin_storage" USING btree ("scope_id","id"); ---> statement-breakpoint -INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") -SELECT - 'plugin_storage_' || md5('openapi:source:' || s."scope_id" || ':' || s."id"), - '["openapi","source",' || to_json(s."id")::text || ']', - s."scope_id", - 'openapi', - 'source', - s."id", - json_build_object( - 'namespace', s."id", - 'scope', s."scope_id", - 'name', s."name", - 'config', json_strip_nulls(json_build_object( - 'spec', s."spec", - 'sourceUrl', s."source_url", - 'baseUrl', s."base_url", - 'headers', h."headers", - 'queryParams', q."queryParams", - 'specFetchCredentials', CASE WHEN sfh."headers" IS NULL AND sfq."queryParams" IS NULL THEN NULL ELSE json_strip_nulls(json_build_object('headers', sfh."headers", 'queryParams', sfq."queryParams")) END, - 'oauth2', s."oauth2" - )) - ), - now(), - now() -FROM "openapi_source" s -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" - FROM "openapi_source_header" - GROUP BY "scope_id", "source_id" -) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" - FROM "openapi_source_query_param" - GROUP BY "scope_id", "source_id" -) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" - FROM "openapi_source_spec_fetch_header" - GROUP BY "scope_id", "source_id" -) sfh ON sfh."scope_id" = s."scope_id" AND sfh."source_id" = s."id" -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" - FROM "openapi_source_spec_fetch_query_param" - GROUP BY "scope_id", "source_id" -) sfq ON sfq."scope_id" = s."scope_id" AND sfq."source_id" = s."id" -ON CONFLICT DO NOTHING; ---> statement-breakpoint -INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") -SELECT 'plugin_storage_' || md5('openapi:operation:' || o."scope_id" || ':' || o."id"), '["openapi","operation",' || to_json(o."id")::text || ']', o."scope_id", 'openapi', 'operation', o."id", json_build_object('toolId', o."id", 'sourceId', o."source_id", 'binding', o."binding"), now(), now() -FROM "openapi_operation" o -ON CONFLICT DO NOTHING; ---> statement-breakpoint -INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") -SELECT - 'plugin_storage_' || md5('graphql:source:' || s."scope_id" || ':' || s."id"), - '["graphql","source",' || to_json(s."id")::text || ']', - s."scope_id", - 'graphql', - 'source', - s."id", - json_build_object( - 'namespace', s."id", - 'scope', s."scope_id", - 'name', s."name", - 'endpoint', s."endpoint", - 'headers', COALESCE(h."headers", '{}'::json), - 'queryParams', COALESCE(q."queryParams", '{}'::json), - 'auth', CASE WHEN s."auth_kind" = 'oauth2' AND s."auth_connection_slot" IS NOT NULL THEN json_build_object('kind', 'oauth2', 'connectionSlot', s."auth_connection_slot") ELSE json_build_object('kind', 'none') END - ), - now(), - now() -FROM "graphql_source" s -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" - FROM "graphql_source_header" - GROUP BY "scope_id", "source_id" -) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" - FROM "graphql_source_query_param" - GROUP BY "scope_id", "source_id" -) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" -ON CONFLICT DO NOTHING; ---> statement-breakpoint -INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") -SELECT 'plugin_storage_' || md5('graphql:operation:' || o."scope_id" || ':' || o."id"), '["graphql","operation",' || to_json(o."id")::text || ']', o."scope_id", 'graphql', 'operation', o."id", json_build_object('toolId', o."id", 'sourceId', o."source_id", 'binding', o."binding"), now(), now() -FROM "graphql_operation" o -ON CONFLICT DO NOTHING; ---> statement-breakpoint -INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") -SELECT - 'plugin_storage_' || md5('mcp:source:' || s."scope_id" || ':' || s."id"), - '["mcp","source",' || to_json(s."id")::text || ']', - s."scope_id", - 'mcp', - 'source', - s."id", - json_build_object( - 'namespace', s."id", - 'scope', s."scope_id", - 'name', s."name", - 'config', CASE WHEN s."config"->>'transport' = 'remote' THEN jsonb_strip_nulls(s."config"::jsonb || jsonb_build_object( - 'headers', h."headers", - 'queryParams', q."queryParams", - 'auth', CASE - WHEN s."auth_kind" = 'header' THEN json_build_object('kind', 'header', 'headerName', COALESCE(s."auth_header_name", ''), 'secretSlot', s."auth_header_slot", 'prefix', s."auth_header_prefix") - WHEN s."auth_kind" = 'oauth2' THEN json_build_object('kind', 'oauth2', 'connectionSlot', s."auth_connection_slot", 'clientIdSlot', s."auth_client_id_slot", 'clientSecretSlot', s."auth_client_secret_slot") - ELSE json_build_object('kind', 'none') - END - )) ELSE s."config"::jsonb END - ), - now(), - now() -FROM "mcp_source" s -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "headers" - FROM "mcp_source_header" - GROUP BY "scope_id", "source_id" -) h ON h."scope_id" = s."scope_id" AND h."source_id" = s."id" -LEFT JOIN ( - SELECT "scope_id", "source_id", json_object_agg("name", CASE WHEN "kind" = 'text' THEN to_json("text_value") ELSE json_build_object('kind', 'binding', 'slot', "slot_key", 'prefix', "prefix") END) AS "queryParams" - FROM "mcp_source_query_param" - GROUP BY "scope_id", "source_id" -) q ON q."scope_id" = s."scope_id" AND q."source_id" = s."id" -ON CONFLICT DO NOTHING; ---> statement-breakpoint -INSERT INTO "plugin_storage" ("row_id", "id", "scope_id", "plugin_id", "collection", "key", "data", "created_at", "updated_at") -SELECT 'plugin_storage_' || md5('mcp:binding:' || b."scope_id" || ':' || b."id"), '["mcp","binding",' || to_json(b."id")::text || ']', b."scope_id", 'mcp', 'binding', b."id", json_build_object('namespace', b."source_id", 'toolId', b."id", 'binding', b."binding"), b."created_at", now() -FROM "mcp_binding" b -ON CONFLICT DO NOTHING; ---> statement-breakpoint -DROP TABLE IF EXISTS "openapi_source"; ---> statement-breakpoint -DROP TABLE IF EXISTS "openapi_operation"; ---> statement-breakpoint -DROP TABLE IF EXISTS "openapi_source_header"; ---> statement-breakpoint -DROP TABLE IF EXISTS "openapi_source_query_param"; ---> statement-breakpoint -DROP TABLE IF EXISTS "openapi_source_spec_fetch_header"; ---> statement-breakpoint -DROP TABLE IF EXISTS "openapi_source_spec_fetch_query_param"; ---> statement-breakpoint -DROP TABLE IF EXISTS "graphql_source"; ---> statement-breakpoint -DROP TABLE IF EXISTS "graphql_source_header"; ---> statement-breakpoint -DROP TABLE IF EXISTS "graphql_source_query_param"; ---> statement-breakpoint -DROP TABLE IF EXISTS "graphql_operation"; ---> statement-breakpoint -DROP TABLE IF EXISTS "mcp_source"; ---> statement-breakpoint -DROP TABLE IF EXISTS "mcp_source_header"; ---> statement-breakpoint -DROP TABLE IF EXISTS "mcp_source_query_param"; ---> statement-breakpoint -DROP TABLE IF EXISTS "mcp_binding"; diff --git a/apps/cloud/drizzle/0018_repair_openapi_oauth_authorization_url.sql b/apps/cloud/drizzle/0018_repair_openapi_oauth_authorization_url.sql deleted file mode 100644 index a2e967981..000000000 --- a/apps/cloud/drizzle/0018_repair_openapi_oauth_authorization_url.sql +++ /dev/null @@ -1,9 +0,0 @@ -UPDATE "plugin_storage" -SET - "data" = jsonb_set("data"::jsonb, '{config,oauth2,authorizationUrl}', 'null'::jsonb, true)::json, - "updated_at" = now() -WHERE - "plugin_id" = 'openapi' - AND "collection" = 'source' - AND "data" #> '{config,oauth2}' IS NOT NULL - AND NOT ("data"::jsonb #> '{config,oauth2}' ? 'authorizationUrl'); diff --git a/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql b/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql deleted file mode 100644 index fa2f7df41..000000000 --- a/apps/cloud/drizzle/0019_workos_vault_plugin_storage.sql +++ /dev/null @@ -1,28 +0,0 @@ -INSERT INTO "plugin_storage" ( - "row_id", - "id", - "scope_id", - "plugin_id", - "collection", - "key", - "data", - "created_at", - "updated_at" -) -SELECT - 'plugin_storage_' || md5('workosVault:metadata:' || m."scope_id" || ':' || m."id"), - '["workosVault","metadata",' || to_json(m."id")::text || ']', - m."scope_id", - 'workosVault', - 'metadata', - m."id", - json_build_object('name', m."name", 'purpose', m."purpose", 'createdAt', m."created_at"), - m."created_at", - now() -FROM "workos_vault_metadata" m -ON CONFLICT ("scope_id", "id") DO UPDATE SET - "data" = EXCLUDED."data", - "updated_at" = EXCLUDED."updated_at"; ---> statement-breakpoint - -DROP TABLE IF EXISTS "workos_vault_metadata"; diff --git a/apps/cloud/drizzle/0020_add_connection_identity_override.sql b/apps/cloud/drizzle/0020_add_connection_identity_override.sql deleted file mode 100644 index f1891e3f2..000000000 --- a/apps/cloud/drizzle/0020_add_connection_identity_override.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "connection" ADD COLUMN IF NOT EXISTS "identity_override" json; diff --git a/apps/cloud/drizzle/meta/0000_snapshot.json b/apps/cloud/drizzle/meta/0000_snapshot.json index 7de7b4cd2..5f7e41541 100644 --- a/apps/cloud/drizzle/meta/0000_snapshot.json +++ b/apps/cloud/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "93e9f8ef-fe9a-4a8c-b326-329464d876ca", + "id": "16a06c56-535a-4774-a9e7-c0807ba74688", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -124,13 +124,13 @@ "columns": { "namespace": { "name": "namespace", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, "key": { "name": "key", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, @@ -139,368 +139,236 @@ "type": "text", "primaryKey": false, "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "id": { + "name": "id", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", + "blob_id_uidx": { + "name": "blob_id_uidx", "columns": [ { - "expression": "plugin_id", + "expression": "id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.graphql_operation": { - "name": "graphql_operation", + "public.connection": { + "name": "connection", "schema": "", "columns": { - "id": { - "name": "id", + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "template": { + "name": "template", "type": "text", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true }, - "binding": { - "name": "binding", - "type": "jsonb", + "item_ids": { + "name": "item_ids", + "type": "json", "primaryKey": false, "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", + "identity_label": { + "name": "identity_label", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "scope_id": { - "name": "scope_id", + "oauth_client": { + "name": "oauth_client", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "name": { - "name": "name", + "oauth_client_owner": { + "name": "oauth_client_owner", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "endpoint": { - "name": "endpoint", + "refresh_item_id": { + "name": "refresh_item_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "headers": { - "name": "headers", - "type": "jsonb", + "expires_at": { + "name": "expires_at", + "type": "bigint", "primaryKey": false, "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", + }, + "oauth_scope": { + "name": "oauth_scope", "type": "text", "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", - "type": "text", + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "binding": { - "name": "binding", - "type": "jsonb", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", + "connection_uidx": { + "name": "connection_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ + }, { - "expression": "source_id", + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.mcp_oauth_session": { - "name": "mcp_oauth_session", + "public.definition": { + "name": "definition", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true }, - "session": { - "name": "session", - "type": "jsonb", + "name": { + "name": "name", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "expires_at": { - "name": "expires_at", - "type": "integer", + "schema": { + "name": "schema", + "type": "json", "primaryKey": false, "notNull": true }, @@ -509,326 +377,320 @@ "type": "timestamp", "primaryKey": false, "notNull": true - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "name": "mcp_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "name": { - "name": "name", - "type": "text", + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "config": { - "name": "config", - "type": "jsonb", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", + "definition_uidx": { + "name": "definition_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.openapi_oauth_session": { - "name": "openapi_oauth_session", + "public.integration": { + "name": "integration", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "slug": { + "name": "slug", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true }, - "session": { - "name": "session", - "type": "jsonb", + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "name": "openapi_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "binding": { - "name": "binding", - "type": "jsonb", + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", + "integration_uidx": { + "name": "integration_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ + }, { - "expression": "source_id", + "expression": "slug", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.openapi_source": { - "name": "openapi_source", + "public.oauth_client": { + "name": "oauth_client", "schema": "", "columns": { - "id": { - "name": "id", + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", "type": "text", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "token_url": { + "name": "token_url", "type": "text", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "grant": { + "name": "grant", "type": "text", "primaryKey": false, "notNull": true }, - "spec": { - "name": "spec", + "client_id": { + "name": "client_id", "type": "text", "primaryKey": false, "notNull": true }, - "base_url": { - "name": "base_url", + "client_secret_item_id": { + "name": "client_secret_item_id", "type": "text", "primaryKey": false, "notNull": false }, - "headers": { - "name": "headers", - "type": "jsonb", + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, - "notNull": false + "notNull": true }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, - "notNull": false + "notNull": true }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", + "oauth_client_uidx": { + "name": "oauth_client_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.secret": { - "name": "secret", + "public.oauth_session": { + "name": "oauth_session", "schema": "", "columns": { - "id": { - "name": "id", + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", "type": "text", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true @@ -839,200 +701,261 @@ "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", "type": "text", "primaryKey": false, "notNull": true }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true } }, "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", + "oauth_session_uidx": { + "name": "oauth_session_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ + }, { - "expression": "provider", + "expression": "state", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.source": { - "name": "source", + "public.plugin_storage": { + "name": "plugin_storage", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "collection": { + "name": "collection", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "plugin_id": { - "name": "plugin_id", - "type": "text", + "key": { + "name": "key", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "kind": { - "name": "kind", - "type": "text", + "data": { + "name": "data", + "type": "json", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", - "type": "text", + "created_at": { + "name": "created_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", + "updated_at": { + "name": "updated_at", + "type": "timestamp", "primaryKey": false, - "notNull": true, - "default": true + "notNull": true }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true }, - "can_edit": { - "name": "can_edit", - "type": "boolean", + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, - "notNull": true, - "default": false + "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, { "expression": "plugin_id", "isExpression": false, "asc": true, "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.tool": { - "name": "tool", + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", "schema": "", "columns": { "id": { "name": "id", - "type": "text", - "primaryKey": false, + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", - "type": "text", + "connection": { + "name": "connection", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, @@ -1044,7 +967,7 @@ }, "name": { "name": "name", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, @@ -1056,13 +979,19 @@ }, "input_schema": { "name": "input_schema", - "type": "jsonb", + "type": "json", "primaryKey": false, "notNull": false }, "output_schema": { "name": "output_schema", - "type": "jsonb", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", "primaryKey": false, "notNull": false }, @@ -1077,126 +1006,188 @@ "type": "timestamp", "primaryKey": false, "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true } }, "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", + "tool_uidx": { + "name": "tool_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ + }, { - "expression": "source_id", + "expression": "owner", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ + }, { - "expression": "plugin_id", + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", + "public.tool_policy": { + "name": "tool_policy", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "pattern": { + "name": "pattern", "type": "text", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "action": { + "name": "action", "type": "text", "primaryKey": false, "notNull": true }, - "purpose": { - "name": "purpose", + "position": { + "name": "position", "type": "text", "primaryKey": false, - "notNull": 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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true } }, "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", + "tool_policy_uidx": { + "name": "tool_policy_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, diff --git a/apps/cloud/drizzle/meta/0001_snapshot.json b/apps/cloud/drizzle/meta/0001_snapshot.json index 78038a13b..6ff6ba2e8 100644 --- a/apps/cloud/drizzle/meta/0001_snapshot.json +++ b/apps/cloud/drizzle/meta/0001_snapshot.json @@ -1,6 +1,6 @@ { - "id": "12ebb69b-fb44-4e61-80b9-a48b5471ef5a", - "prevId": "93e9f8ef-fe9a-4a8c-b326-329464d876ca", + "id": "c4b68576-7550-4ed0-98df-69072f0ec71e", + "prevId": "16a06c56-535a-4774-a9e7-c0807ba74688", "version": "7", "dialect": "postgresql", "tables": { @@ -124,13 +124,13 @@ "columns": { "namespace": { "name": "namespace", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, "key": { "name": "key", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, @@ -139,212 +139,223 @@ "type": "text", "primaryKey": false, "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.definition": { - "name": "definition", + "public.connection": { + "name": "connection", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "integration": { + "name": "integration", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "name": { + "name": "name", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", + "template": { + "name": "template", "type": "text", "primaryKey": false, "notNull": true }, - "plugin_id": { - "name": "plugin_id", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", - "type": "text", + "item_ids": { + "name": "item_ids", + "type": "json", "primaryKey": false, "notNull": true }, - "schema": { - "name": "schema", - "type": "jsonb", + "identity_label": { + "name": "identity_label", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", - "type": "text", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "binding": { - "name": "binding", - "type": "jsonb", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", + "connection_uidx": { + "name": "connection_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, { - "expression": "source_id", + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.graphql_source": { - "name": "graphql_source", + "public.definition": { + "name": "definition", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true @@ -355,398 +366,343 @@ "primaryKey": false, "notNull": true }, - "endpoint": { - "name": "endpoint", - "type": "text", + "schema": { + "name": "schema", + "type": "json", "primaryKey": false, "notNull": true }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", + "created_at": { + "name": "created_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "source_id": { - "name": "source_id", - "type": "text", + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "binding": { - "name": "binding", - "type": "jsonb", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", + "definition_uidx": { + "name": "definition_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ + }, { - "expression": "source_id", + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.mcp_oauth_session": { - "name": "mcp_oauth_session", + "public.integration": { + "name": "integration", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "slug": { + "name": "slug", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true }, - "session": { - "name": "session", - "type": "jsonb", + "description": { + "name": "description", + "type": "text", "primaryKey": false, "notNull": true }, - "expires_at": { - "name": "expires_at", - "type": "integer", + "config": { + "name": "config", + "type": "json", "primaryKey": false, - "notNull": true + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true } }, "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", + "integration_uidx": { + "name": "integration_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "name": "mcp_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.mcp_source": { - "name": "mcp_source", + "public.oauth_client": { + "name": "oauth_client", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "slug": { + "name": "slug", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "authorization_url": { + "name": "authorization_url", "type": "text", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "token_url": { + "name": "token_url", "type": "text", "primaryKey": false, "notNull": true }, - "config": { - "name": "config", - "type": "jsonb", + "grant": { + "name": "grant", + "type": "text", "primaryKey": false, "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "client_id": { + "name": "client_id", + "type": "text", "primaryKey": false, "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_oauth_session": { - "name": "openapi_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", + }, + "client_secret_item_id": { + "name": "client_secret_item_id", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, - "scope_id": { - "name": "scope_id", + "resource": { + "name": "resource", "type": "text", "primaryKey": false, - "notNull": true + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false }, - "session": { - "name": "session", - "type": "jsonb", + "origin_integration": { + "name": "origin_integration", + "type": "text", "primaryKey": false, - "notNull": true + "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "name": "openapi_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", - "type": "text", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "binding": { - "name": "binding", - "type": "jsonb", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", + "oauth_client_uidx": { + "name": "oauth_client_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, { - "expression": "source_id", + "expression": "slug", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] + "with": {} } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.openapi_source": { - "name": "openapi_source", + "public.oauth_session": { + "name": "oauth_session", "schema": "", "columns": { - "id": { - "name": "id", + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", "type": "text", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true @@ -757,288 +713,261 @@ "primaryKey": false, "notNull": true }, - "spec": { - "name": "spec", + "template": { + "name": "template", "type": "text", "primaryKey": false, "notNull": true }, - "source_url": { - "name": "source_url", + "redirect_url": { + "name": "redirect_url", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "base_url": { - "name": "base_url", + "pkce_verifier": { + "name": "pkce_verifier", "type": "text", "primaryKey": false, "notNull": false }, - "headers": { - "name": "headers", - "type": "jsonb", + "identity_label": { + "name": "identity_label", + "type": "text", "primaryKey": false, "notNull": false }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", + "payload": { + "name": "payload", + "type": "json", "primaryKey": false, - "notNull": false + "notNull": true }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", + "expires_at": { + "name": "expires_at", + "type": "bigint", "primaryKey": false, "notNull": true - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", + }, + "created_at": { + "name": "created_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "name": { - "name": "name", - "type": "text", + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "provider": { - "name": "provider", - "type": "text", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", + "oauth_session_uidx": { + "name": "oauth_session_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ + }, { - "expression": "provider", + "expression": "state", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.source": { - "name": "source", + "public.plugin_storage": { + "name": "plugin_storage", "schema": "", "columns": { - "id": { - "name": "id", - "type": "text", + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "collection": { + "name": "collection", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "plugin_id": { - "name": "plugin_id", - "type": "text", + "key": { + "name": "key", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "kind": { - "name": "kind", - "type": "text", + "data": { + "name": "data", + "type": "json", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", - "type": "text", + "created_at": { + "name": "created_at", + "type": "timestamp", "primaryKey": false, "notNull": true }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", + "updated_at": { + "name": "updated_at", + "type": "timestamp", "primaryKey": false, - "notNull": true, - "default": true + "notNull": true }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true }, - "can_edit": { - "name": "can_edit", - "type": "boolean", + "tenant": { + "name": "tenant", + "type": "varchar(255)", "primaryKey": false, - "notNull": true, - "default": false + "notNull": true }, - "created_at": { - "name": "created_at", - "type": "timestamp", + "owner": { + "name": "owner", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", + "subject": { + "name": "subject", + "type": "varchar(255)", "primaryKey": false, "notNull": true } }, "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, { "expression": "plugin_id", "isExpression": false, "asc": true, "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.tool": { - "name": "tool", + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", "schema": "", "columns": { "id": { "name": "id", - "type": "text", - "primaryKey": false, + "type": "varchar(255)", + "primaryKey": true, "notNull": true }, - "scope_id": { - "name": "scope_id", - "type": "text", + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "source_id": { - "name": "source_id", - "type": "text", + "connection": { + "name": "connection", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, @@ -1050,7 +979,7 @@ }, "name": { "name": "name", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, @@ -1062,13 +991,19 @@ }, "input_schema": { "name": "input_schema", - "type": "jsonb", + "type": "json", "primaryKey": false, "notNull": false }, "output_schema": { "name": "output_schema", - "type": "jsonb", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", "primaryKey": false, "notNull": false }, @@ -1083,126 +1018,188 @@ "type": "timestamp", "primaryKey": false, "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true } }, "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", + "tool_uidx": { + "name": "tool_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ + }, { - "expression": "source_id", + "expression": "owner", "isExpression": false, "asc": true, "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ + }, { - "expression": "plugin_id", + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", + "public.tool_policy": { + "name": "tool_policy", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "varchar(255)", "primaryKey": false, "notNull": true }, - "scope_id": { - "name": "scope_id", + "pattern": { + "name": "pattern", "type": "text", "primaryKey": false, "notNull": true }, - "name": { - "name": "name", + "action": { + "name": "action", "type": "text", "primaryKey": false, "notNull": true }, - "purpose": { - "name": "purpose", + "position": { + "name": "position", "type": "text", "primaryKey": false, - "notNull": 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 + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true } }, "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", + "tool_policy_uidx": { + "name": "tool_policy_uidx", "columns": [ { - "expression": "scope_id", + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, diff --git a/apps/cloud/drizzle/meta/0002_snapshot.json b/apps/cloud/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 4d85af660..000000000 --- a/apps/cloud/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,1223 +0,0 @@ -{ - "id": "f029f809-1bf3-46e0-a31b-82df7c9c7171", - "prevId": "12ebb69b-fb44-4e61-80b9-a48b5471ef5a", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_oauth_session": { - "name": "mcp_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "name": "mcp_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_oauth_session": { - "name": "openapi_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "name": "openapi_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0003_snapshot.json b/apps/cloud/drizzle/meta/0003_snapshot.json deleted file mode 100644 index baf6f3ed3..000000000 --- a/apps/cloud/drizzle/meta/0003_snapshot.json +++ /dev/null @@ -1,1365 +0,0 @@ -{ - "id": "09d08343-8162-4e6b-91ab-ce0a9d6bad10", - "prevId": "f029f809-1bf3-46e0-a31b-82df7c9c7171", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "jsonb", - "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": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "name": "connection_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_oauth_session": { - "name": "mcp_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "name": "mcp_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_oauth_session": { - "name": "openapi_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "name": "openapi_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": [ - { - "expression": "owned_by_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0004_snapshot.json b/apps/cloud/drizzle/meta/0004_snapshot.json deleted file mode 100644 index 036034e6f..000000000 --- a/apps/cloud/drizzle/meta/0004_snapshot.json +++ /dev/null @@ -1,1487 +0,0 @@ -{ - "id": "626f3e78-1eda-40a0-b97f-2562e89205ec", - "prevId": "09d08343-8162-4e6b-91ab-ce0a9d6bad10", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "jsonb", - "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": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "name": "connection_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_oauth_session": { - "name": "mcp_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "name": "mcp_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_oauth_session": { - "name": "openapi_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "name": "openapi_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": [ - { - "expression": "owned_by_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_binding": { - "name": "openapi_source_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "jsonb", - "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": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": [ - { - "expression": "source_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": [ - { - "expression": "target_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": [ - { - "expression": "slot", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0005_snapshot.json b/apps/cloud/drizzle/meta/0005_snapshot.json deleted file mode 100644 index c31ddfd8f..000000000 --- a/apps/cloud/drizzle/meta/0005_snapshot.json +++ /dev/null @@ -1,1481 +0,0 @@ -{ - "id": "1d422fe0-aa63-4931-80dc-02816df3c4d2", - "prevId": "626f3e78-1eda-40a0-b97f-2562e89205ec", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "jsonb", - "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": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "name": "connection_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_oauth_session": { - "name": "mcp_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "name": "mcp_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_oauth_session": { - "name": "openapi_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "name": "openapi_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": [ - { - "expression": "owned_by_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_binding": { - "name": "openapi_source_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "jsonb", - "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": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": [ - { - "expression": "source_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": [ - { - "expression": "target_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": [ - { - "expression": "slot", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0006_snapshot.json b/apps/cloud/drizzle/meta/0006_snapshot.json deleted file mode 100644 index 15370faf5..000000000 --- a/apps/cloud/drizzle/meta/0006_snapshot.json +++ /dev/null @@ -1,1563 +0,0 @@ -{ - "id": "2ab0a02b-4dba-4ea4-bb6a-31ef0926942f", - "prevId": "1d422fe0-aa63-4931-80dc-02816df3c4d2", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "jsonb", - "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": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "name": "connection_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_oauth_session": { - "name": "mcp_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "name": "mcp_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_oauth_session": { - "name": "openapi_oauth_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "session": { - "name": "session", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "name": "openapi_oauth_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": [ - { - "expression": "owned_by_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_binding": { - "name": "openapi_source_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "jsonb", - "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": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": [ - { - "expression": "source_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": [ - { - "expression": "target_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": [ - { - "expression": "slot", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool_policy": { - "name": "tool_policy", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "position": { - "name": "position", - "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": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "position", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "name": "tool_policy_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0007_snapshot.json b/apps/cloud/drizzle/meta/0007_snapshot.json deleted file mode 100644 index 973b3d9d3..000000000 --- a/apps/cloud/drizzle/meta/0007_snapshot.json +++ /dev/null @@ -1,1583 +0,0 @@ -{ - "id": "f521e4d8-1eb4-4f84-8110-38fb5157aaca", - "prevId": "2ab0a02b-4dba-4ea4-bb6a-31ef0926942f", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "jsonb", - "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": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "name": "connection_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "query_params": { - "name": "query_params", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "auth": { - "name": "auth", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.oauth2_session": { - "name": "oauth2_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "strategy": { - "name": "strategy", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_scope": { - "name": "token_scope", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "oauth2_session_scope_id_idx": { - "name": "oauth2_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "oauth2_session_plugin_id_idx": { - "name": "oauth2_session_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "oauth2_session_connection_id_idx": { - "name": "oauth2_session_connection_id_idx", - "columns": [ - { - "expression": "connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "oauth2_session_scope_id_id_pk": { - "name": "oauth2_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "query_params": { - "name": "query_params", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_binding": { - "name": "openapi_source_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "jsonb", - "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": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": [ - { - "expression": "source_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": [ - { - "expression": "target_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": [ - { - "expression": "slot", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": [ - { - "expression": "owned_by_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool_policy": { - "name": "tool_policy", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "position": { - "name": "position", - "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": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "position", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "name": "tool_policy_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0008_snapshot.json b/apps/cloud/drizzle/meta/0008_snapshot.json deleted file mode 100644 index e556d775c..000000000 --- a/apps/cloud/drizzle/meta/0008_snapshot.json +++ /dev/null @@ -1,2517 +0,0 @@ -{ - "id": "b8d89563-58e1-4e6b-8674-f502338978e2", - "prevId": "f521e4d8-1eb4-4f84-8110-38fb5157aaca", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "jsonb", - "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": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "name": "connection_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "none" - }, - "auth_connection_id": { - "name": "auth_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_source_auth_connection_id_idx": { - "name": "graphql_source_auth_connection_id_idx", - "columns": [ - { - "expression": "auth_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "auth_header_name": { - "name": "auth_header_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_secret_id": { - "name": "auth_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_secret_prefix": { - "name": "auth_secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_connection_id": { - "name": "auth_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_client_id_secret_id": { - "name": "auth_client_id_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_client_secret_secret_id": { - "name": "auth_client_secret_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_auth_secret_id_idx": { - "name": "mcp_source_auth_secret_id_idx", - "columns": [ - { - "expression": "auth_secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_auth_connection_id_idx": { - "name": "mcp_source_auth_connection_id_idx", - "columns": [ - { - "expression": "auth_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_auth_client_id_secret_id_idx": { - "name": "mcp_source_auth_client_id_secret_id_idx", - "columns": [ - { - "expression": "auth_client_id_secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_auth_client_secret_secret_id_idx": { - "name": "mcp_source_auth_client_secret_secret_id_idx", - "columns": [ - { - "expression": "auth_client_secret_secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.oauth2_session": { - "name": "oauth2_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "strategy": { - "name": "strategy", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_scope": { - "name": "token_scope", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "oauth2_session_scope_id_idx": { - "name": "oauth2_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "oauth2_session_plugin_id_idx": { - "name": "oauth2_session_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "oauth2_session_connection_id_idx": { - "name": "oauth2_session_connection_id_idx", - "columns": [ - { - "expression": "connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "oauth2_session_scope_id_id_pk": { - "name": "oauth2_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "headers": { - "name": "headers", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_binding": { - "name": "openapi_source_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slot": { - "name": "slot", - "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 - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'text'" - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": [ - { - "expression": "source_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": [ - { - "expression": "target_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": [ - { - "expression": "slot", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_secret_id_idx": { - "name": "openapi_source_binding_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_binding_connection_id_idx": { - "name": "openapi_source_binding_connection_id_idx", - "columns": [ - { - "expression": "connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": [ - { - "expression": "owned_by_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool_policy": { - "name": "tool_policy", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "position": { - "name": "position", - "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": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "position", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "name": "tool_policy_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source_header": { - "name": "graphql_source_header", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_header_scope_id_idx": { - "name": "graphql_source_header_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_source_header_source_id_idx": { - "name": "graphql_source_header_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_source_header_secret_id_idx": { - "name": "graphql_source_header_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_header_scope_id_id_pk": { - "name": "graphql_source_header_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source_query_param": { - "name": "graphql_source_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_query_param_scope_id_idx": { - "name": "graphql_source_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_source_query_param_source_id_idx": { - "name": "graphql_source_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_source_query_param_secret_id_idx": { - "name": "graphql_source_query_param_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_query_param_scope_id_id_pk": { - "name": "graphql_source_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_query_param": { - "name": "openapi_source_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_query_param_scope_id_idx": { - "name": "openapi_source_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_query_param_source_id_idx": { - "name": "openapi_source_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_query_param_secret_id_idx": { - "name": "openapi_source_query_param_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_query_param_scope_id_id_pk": { - "name": "openapi_source_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_spec_fetch_header": { - "name": "openapi_source_spec_fetch_header", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_spec_fetch_header_scope_id_idx": { - "name": "openapi_source_spec_fetch_header_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_spec_fetch_header_source_id_idx": { - "name": "openapi_source_spec_fetch_header_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_spec_fetch_header_secret_id_idx": { - "name": "openapi_source_spec_fetch_header_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_header_scope_id_id_pk": { - "name": "openapi_source_spec_fetch_header_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_spec_fetch_query_param": { - "name": "openapi_source_spec_fetch_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_spec_fetch_query_param_scope_id_idx": { - "name": "openapi_source_spec_fetch_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_spec_fetch_query_param_source_id_idx": { - "name": "openapi_source_spec_fetch_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_spec_fetch_query_param_secret_id_idx": { - "name": "openapi_source_spec_fetch_query_param_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_query_param_scope_id_id_pk": { - "name": "openapi_source_spec_fetch_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source_header": { - "name": "mcp_source_header", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "mcp_source_header_scope_id_idx": { - "name": "mcp_source_header_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_header_source_id_idx": { - "name": "mcp_source_header_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_header_secret_id_idx": { - "name": "mcp_source_header_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_header_scope_id_id_pk": { - "name": "mcp_source_header_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source_query_param": { - "name": "mcp_source_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "mcp_source_query_param_scope_id_idx": { - "name": "mcp_source_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_query_param_source_id_idx": { - "name": "mcp_source_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_query_param_secret_id_idx": { - "name": "mcp_source_query_param_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_query_param_scope_id_id_pk": { - "name": "mcp_source_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0009_snapshot.json b/apps/cloud/drizzle/meta/0009_snapshot.json deleted file mode 100644 index 6a0921c46..000000000 --- a/apps/cloud/drizzle/meta/0009_snapshot.json +++ /dev/null @@ -1,2468 +0,0 @@ -{ - "id": "3b53ed57-f0b1-40a4-9929-dd399180a17a", - "prevId": "b8d89563-58e1-4e6b-8674-f502338978e2", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "name": "blob_namespace_key_pk", - "columns": ["namespace", "key"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "jsonb", - "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": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "name": "connection_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.credential_binding": { - "name": "credential_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "connection_id": { - "name": "connection_id", - "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": { - "credential_binding_scope_id_idx": { - "name": "credential_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "credential_binding_plugin_id_idx": { - "name": "credential_binding_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "credential_binding_source_id_idx": { - "name": "credential_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "credential_binding_source_scope_id_idx": { - "name": "credential_binding_source_scope_id_idx", - "columns": [ - { - "expression": "source_scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "credential_binding_slot_key_idx": { - "name": "credential_binding_slot_key_idx", - "columns": [ - { - "expression": "slot_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "credential_binding_kind_idx": { - "name": "credential_binding_kind_idx", - "columns": [ - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "credential_binding_secret_id_idx": { - "name": "credential_binding_secret_id_idx", - "columns": [ - { - "expression": "secret_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "credential_binding_connection_id_idx": { - "name": "credential_binding_connection_id_idx", - "columns": [ - { - "expression": "connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "credential_binding_scope_id_id_pk": { - "name": "credential_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "name": "definition_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "name": "graphql_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "auth_connection_slot": { - "name": "auth_connection_slot", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "name": "graphql_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source_header": { - "name": "graphql_source_header", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_header_scope_id_idx": { - "name": "graphql_source_header_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_source_header_source_id_idx": { - "name": "graphql_source_header_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_header_scope_id_id_pk": { - "name": "graphql_source_header_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source_query_param": { - "name": "graphql_source_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_query_param_scope_id_idx": { - "name": "graphql_source_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "graphql_source_query_param_source_id_idx": { - "name": "graphql_source_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_query_param_scope_id_id_pk": { - "name": "graphql_source_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "name": "mcp_binding_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_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 - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "auth_header_name": { - "name": "auth_header_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_header_slot": { - "name": "auth_header_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_header_prefix": { - "name": "auth_header_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_connection_slot": { - "name": "auth_connection_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_client_id_slot": { - "name": "auth_client_id_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_client_secret_slot": { - "name": "auth_client_secret_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "name": "mcp_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source_header": { - "name": "mcp_source_header", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "mcp_source_header_scope_id_idx": { - "name": "mcp_source_header_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_header_source_id_idx": { - "name": "mcp_source_header_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_header_scope_id_id_pk": { - "name": "mcp_source_header_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source_query_param": { - "name": "mcp_source_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "mcp_source_query_param_scope_id_idx": { - "name": "mcp_source_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "mcp_source_query_param_source_id_idx": { - "name": "mcp_source_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_query_param_scope_id_id_pk": { - "name": "mcp_source_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.oauth2_session": { - "name": "oauth2_session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "strategy": { - "name": "strategy", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_scope": { - "name": "token_scope", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "oauth2_session_scope_id_idx": { - "name": "oauth2_session_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "oauth2_session_plugin_id_idx": { - "name": "oauth2_session_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "oauth2_session_connection_id_idx": { - "name": "oauth2_session_connection_id_idx", - "columns": [ - { - "expression": "connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "oauth2_session_scope_id_id_pk": { - "name": "oauth2_session_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "name": "openapi_operation_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "name": "openapi_source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_header": { - "name": "openapi_source_header", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_header_scope_id_idx": { - "name": "openapi_source_header_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_header_source_id_idx": { - "name": "openapi_source_header_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_header_scope_id_id_pk": { - "name": "openapi_source_header_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_query_param": { - "name": "openapi_source_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_query_param_scope_id_idx": { - "name": "openapi_source_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_query_param_source_id_idx": { - "name": "openapi_source_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_query_param_scope_id_id_pk": { - "name": "openapi_source_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_spec_fetch_header": { - "name": "openapi_source_spec_fetch_header", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_spec_fetch_header_scope_id_idx": { - "name": "openapi_source_spec_fetch_header_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_spec_fetch_header_source_id_idx": { - "name": "openapi_source_spec_fetch_header_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_header_scope_id_id_pk": { - "name": "openapi_source_spec_fetch_header_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_spec_fetch_query_param": { - "name": "openapi_source_spec_fetch_query_param", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_spec_fetch_query_param_scope_id_idx": { - "name": "openapi_source_spec_fetch_query_param_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "openapi_source_spec_fetch_query_param_source_id_idx": { - "name": "openapi_source_spec_fetch_query_param_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_query_param_scope_id_id_pk": { - "name": "openapi_source_spec_fetch_query_param_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": [ - { - "expression": "owned_by_connection_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "name": "secret_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "name": "source_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "jsonb", - "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": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": [ - { - "expression": "source_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": [ - { - "expression": "plugin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "name": "tool_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool_policy": { - "name": "tool_policy", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "position": { - "name": "position", - "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": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "position", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "name": "tool_policy_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_idx": { - "name": "workos_vault_metadata_scope_id_idx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "workos_vault_metadata_scope_id_id_pk": { - "name": "workos_vault_metadata_scope_id_id_pk", - "columns": ["scope_id", "id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/0016_snapshot.json b/apps/cloud/drizzle/meta/0016_snapshot.json deleted file mode 100644 index 9642ba3bf..000000000 --- a/apps/cloud/drizzle/meta/0016_snapshot.json +++ /dev/null @@ -1,2258 +0,0 @@ -{ - "id": "77c36b81-283e-4a48-989b-a12cce8d0651", - "prevId": "3b53ed57-f0b1-40a4-9929-dd399180a17a", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.accounts": { - "name": "accounts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.memberships": { - "name": "memberships", - "schema": "", - "columns": { - "account_id": { - "name": "account_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 with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "memberships_account_id_accounts_id_fk": { - "name": "memberships_account_id_accounts_id_fk", - "tableFrom": "memberships", - "tableTo": "accounts", - "columnsFrom": ["account_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "memberships_organization_id_organizations_id_fk": { - "name": "memberships_organization_id_organizations_id_fk", - "tableFrom": "memberships", - "tableTo": "organizations", - "columnsFrom": ["organization_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "memberships_account_id_organization_id_pk": { - "name": "memberships_account_id_organization_id_pk", - "columns": ["account_id", "organization_id"] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organizations": { - "name": "organizations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.blob": { - "name": "blob", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "blob_id_uidx": { - "name": "blob_id_uidx", - "columns": [ - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.connection": { - "name": "connection", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_state": { - "name": "provider_state", - "type": "json", - "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": { - "connection_scope_id_id_uidx": { - "name": "connection_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.credential_binding": { - "name": "credential_binding", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "secret_scope_id": { - "name": "secret_scope_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "connection_id": { - "name": "connection_id", - "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": { - "credential_binding_scope_id_id_uidx": { - "name": "credential_binding_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.definition": { - "name": "definition", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "schema": { - "name": "schema", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "definition_scope_id_id_uidx": { - "name": "definition_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_operation": { - "name": "graphql_operation", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "json", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "graphql_operation_scope_id_id_uidx": { - "name": "graphql_operation_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source": { - "name": "graphql_source", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "auth_connection_slot": { - "name": "auth_connection_slot", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_scope_id_id_uidx": { - "name": "graphql_source_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source_header": { - "name": "graphql_source_header", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_header_scope_id_id_uidx": { - "name": "graphql_source_header_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.graphql_source_query_param": { - "name": "graphql_source_query_param", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "graphql_source_query_param_scope_id_id_uidx": { - "name": "graphql_source_query_param_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_binding": { - "name": "mcp_binding", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_binding_scope_id_id_uidx": { - "name": "mcp_binding_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source": { - "name": "mcp_source", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "config": { - "name": "config", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'none'" - }, - "auth_header_name": { - "name": "auth_header_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_header_slot": { - "name": "auth_header_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_header_prefix": { - "name": "auth_header_prefix", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_connection_slot": { - "name": "auth_connection_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_client_id_slot": { - "name": "auth_client_id_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "auth_client_secret_slot": { - "name": "auth_client_secret_slot", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "mcp_source_scope_id_id_uidx": { - "name": "mcp_source_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source_header": { - "name": "mcp_source_header", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "mcp_source_header_scope_id_id_uidx": { - "name": "mcp_source_header_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.mcp_source_query_param": { - "name": "mcp_source_query_param", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "mcp_source_query_param_scope_id_id_uidx": { - "name": "mcp_source_query_param_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.oauth2_session": { - "name": "oauth2_session", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "strategy": { - "name": "strategy", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_scope": { - "name": "token_scope", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "oauth2_session_scope_id_id_uidx": { - "name": "oauth2_session_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_operation": { - "name": "openapi_operation", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "binding": { - "name": "binding", - "type": "json", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "openapi_operation_scope_id_id_uidx": { - "name": "openapi_operation_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source": { - "name": "openapi_source", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "oauth2": { - "name": "oauth2", - "type": "json", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_scope_id_id_uidx": { - "name": "openapi_source_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_header": { - "name": "openapi_source_header", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_header_scope_id_id_uidx": { - "name": "openapi_source_header_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_query_param": { - "name": "openapi_source_query_param", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_query_param_scope_id_id_uidx": { - "name": "openapi_source_query_param_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_spec_fetch_header": { - "name": "openapi_source_spec_fetch_header", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_spec_fetch_header_scope_id_id_uidx": { - "name": "openapi_source_spec_fetch_header_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openapi_source_spec_fetch_query_param": { - "name": "openapi_source_spec_fetch_query_param", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "openapi_source_spec_fetch_query_param_scope_id_id_uidx": { - "name": "openapi_source_spec_fetch_query_param_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.private_executor_cloud_settings": { - "name": "private_executor_cloud_settings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "version": { - "name": "version", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true, - "default": "'1.0.0'" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.secret": { - "name": "secret", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "secret_scope_id_id_uidx": { - "name": "secret_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.source": { - "name": "source", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "can_remove": { - "name": "can_remove", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "source_scope_id_id_uidx": { - "name": "source_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool": { - "name": "tool", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "input_schema": { - "name": "input_schema", - "type": "json", - "primaryKey": false, - "notNull": false - }, - "output_schema": { - "name": "output_schema", - "type": "json", - "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": { - "tool_scope_id_id_uidx": { - "name": "tool_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.tool_policy": { - "name": "tool_policy", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "position": { - "name": "position", - "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": { - "tool_policy_scope_id_id_uidx": { - "name": "tool_policy_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.workos_vault_metadata": { - "name": "workos_vault_metadata", - "schema": "", - "columns": { - "row_id": { - "name": "row_id", - "type": "varchar(255)", - "primaryKey": true, - "notNull": true - }, - "id": { - "name": "id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "scope_id": { - "name": "scope_id", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "purpose": { - "name": "purpose", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "workos_vault_metadata_scope_id_id_uidx": { - "name": "workos_vault_metadata_scope_id_id_uidx", - "columns": [ - { - "expression": "scope_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 6f1cee27d..7b2e34011 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -5,148 +5,15 @@ { "idx": 0, "version": "7", - "when": 1776367874348, - "tag": "0000_lame_rage", + "when": 1780729919110, + "tag": "0000_v2_baseline", "breakpoints": true }, { "idx": 1, "version": "7", - "when": 1776678968180, - "tag": "0001_harsh_meltdown", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1776709954401, - "tag": "0002_fat_white_tiger", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1776728656793, - "tag": "0003_add_connections", - "breakpoints": true - }, - { - "idx": 4, - "version": "7", - "when": 1776997871000, - "tag": "0004_openapi_source_bindings", - "breakpoints": true - }, - { - "idx": 5, - "version": "7", - "when": 1777000000000, - "tag": "0005_drop_connection_kind", - "breakpoints": true - }, - { - "idx": 6, - "version": "7", - "when": 1777444003590, - "tag": "0006_add_tool_policy", - "breakpoints": true - }, - { - "idx": 7, - "version": "7", - "when": 1777567556847, - "tag": "0007_military_young_avengers", - "breakpoints": true - }, - { - "idx": 8, - "version": "7", - "when": 1778004191000, - "tag": "0008_normalize_plugin_secret_refs", - "breakpoints": true - }, - { - "idx": 9, - "version": "7", - "when": 1778128200000, - "tag": "0009_scoped_credentials_cutover", - "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1778177700000, - "tag": "0010_repair_mcp_connection_binding_scopes", - "breakpoints": true - }, - { - "idx": 11, - "version": "7", - "when": 1778178300000, - "tag": "0011_repair_openapi_connection_binding_scopes", - "breakpoints": true - }, - { - "idx": 12, - "version": "7", - "when": 1778179000000, - "tag": "0012_repair_openapi_secret_binding_scopes", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1778179800000, - "tag": "0013_cleanup_orphan_oauth_rows", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1778192434062, - "tag": "0014_repair_openapi_oauth_cutover_residue", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1778192434063, - "tag": "0015_add_credential_binding_secret_scope", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1778781460169, - "tag": "0016_fumadb_cutover", - "breakpoints": true - }, - { - "idx": 17, - "version": "7", - "when": 1779087600000, - "tag": "0017_plugin_storage_sources", - "breakpoints": true - }, - { - "idx": 18, - "version": "7", - "when": 1779199200000, - "tag": "0018_repair_openapi_oauth_authorization_url", - "breakpoints": true - }, - { - "idx": 19, - "version": "7", - "when": 1780081200000, - "tag": "0019_workos_vault_plugin_storage", - "breakpoints": true - }, - { - "idx": 20, - "version": "7", - "when": 1780124534904, - "tag": "0020_add_connection_identity_override", + "when": 1780991784259, + "tag": "0001_illegal_wolverine", "breakpoints": true } ] diff --git a/apps/cloud/e2e/client-entry-hydration.spec.ts b/apps/cloud/e2e/client-entry-hydration.spec.ts deleted file mode 100644 index 6833192e6..000000000 --- a/apps/cloud/e2e/client-entry-hydration.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, test } from "@playwright/test"; - -// --------------------------------------------------------------------------- -// Regression guard: the cloud SPA must HYDRATE in a real browser. -// -// Motivating failure — on launch the console showed: -// -// Uncaught (in promise) TypeError: Failed to fetch dynamically imported -// module: /@id/virtual:tanstack-start-client-entry -// TypeError: Cannot read properties of undefined (reading 'has') -// -// …with a swarm of `net::ERR_ABORTED` on in-flight module requests. -// -// Root cause: that is Vite's *cold-start dependency re-optimization reload*. The -// first load after the import graph changes makes Vite re-bundle a late-discovered -// dep and force a full page reload, which aborts the in-flight client-entry import. -// It self-heals on the next load (hydration then succeeds). So the warm-up -// navigation below deliberately absorbs that benign one-time reload; the MEASURED -// navigation must then come up clean. -// -// What this guards against is the *persistent* version: the client entry failing -// to load on a settled server, leaving the app permanently dead. That is invisible -// to a request-level test (every module serves a clean 200 to `curl`) — it only -// surfaces in a browser running the module graph. Hence Playwright, booted by -// playwright.config.ts's webServer against a stub-env Vite dev + throwaway PGlite. -// --------------------------------------------------------------------------- - -// Only Vite's own dev module-graph URLs — the client entry and everything it -// statically/dynamically imports. Deliberately excludes third-party scripts -// (e.g. analytics under /api/a/static) that have their own, unrelated lifecycle. -const isViteModuleRequest = (url: string) => - url.includes("/@id/") || url.includes("/@fs/") || url.includes("/node_modules/.vite/"); - -test("the client entry hydrates — the SPA mounts, no dynamic-import failure", async ({ page }) => { - // Warm-up: the first cold load may trigger Vite's one-time dep re-optimize + - // reload. Swallow it here so the measured pass below sees a settled server. - await page.goto("/", { waitUntil: "load" }); - await page.waitForTimeout(1500); - - const fatal: string[] = []; - const abortedModules: string[] = []; - - // A persistent hydration failure surfaces as an unhandled rejection ("Failed to - // fetch dynamically imported module") and/or a thrown TypeError; capture both. - await page.addInitScript(() => { - window.addEventListener("unhandledrejection", (event) => { - console.error(`UNHANDLED_REJECTION: ${String(event.reason)}`); - }); - }); - page.on("console", (message) => { - const text = message.text(); - if ( - /failed to fetch dynamically imported module/i.test(text) || - /tanstack-start-client-entry/i.test(text) || - /UNHANDLED_REJECTION/i.test(text) - ) { - fatal.push(`[console.${message.type()}] ${text}`); - } - }); - page.on("pageerror", (error) => fatal.push(`[pageerror] ${String(error)}`)); - page.on("requestfailed", (request) => { - const failure = request.failure()?.errorText ?? ""; - if (/ERR_ABORTED/i.test(failure) && isViteModuleRequest(request.url())) { - abortedModules.push(`${failure} ${request.url()}`); - } - }); - - // Measured pass against the now-settled server. - await page.goto("/", { waitUntil: "load" }); - await page.waitForTimeout(2500); - - // The SSR shell always carries the title; that alone does NOT prove hydration. - await expect(page).toHaveTitle(/Executor/i); - - // (1) No dynamic-import / hydration crash. - expect(fatal, `client-entry/hydration errors:\n${fatal.join("\n")}`).toEqual([]); - - // (2) No aborted module fetches — the signature of the client entry failing to - // load (a stuck re-optimize, a boundary leak, a broken transform). - expect( - abortedModules, - `module requests were aborted (client entry did not load cleanly):\n${abortedModules.join("\n")}`, - ).toEqual([]); - - // (3) The client runtime actually booted: TanStack Start/Router installs its - // router on `window` during hydration. This is true regardless of auth state - // (the stub session is unauthenticated, so there's little rendered text to - // assert on — but a mounted client always exposes the router). - const hydrated = await page.evaluate( - () => Reflect.has(window, "__TSR_ROUTER__") || Reflect.has(window, "__TSR__"), - ); - expect(hydrated, "TanStack Start router never mounted — the SPA did not hydrate").toBe(true); -}); diff --git a/apps/cloud/e2e/e2e-server.ts b/apps/cloud/e2e/e2e-server.ts deleted file mode 100644 index 823e394aa..000000000 --- a/apps/cloud/e2e/e2e-server.ts +++ /dev/null @@ -1,67 +0,0 @@ -// --------------------------------------------------------------------------- -// Boots the cloud app's Vite dev server for the Playwright e2e suite — the SAME -// dev stack a developer runs (`bun run dev`), minus 1Password / real WorkOS. -// -// Everything here is a STUB: fake WorkOS creds, a fixed cookie/encryption key, -// and a throwaway PGlite on its own port (so it never collides with a running -// `bun dev`). That's deliberate — what the spec guards (the TanStack Start client -// entry hydrating) is a CLIENT-side module-graph concern that doesn't depend on -// any of these values, so the stub config is sufficient and the harness stays -// runnable in CI with no secrets. -// -// Used by `playwright.config.ts`'s `webServer`. Spawns the dev DB + Vite, wires -// their stdout through, and tears both down on exit. -// --------------------------------------------------------------------------- - -import { spawn, type ChildProcess } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const appDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - -const PORT = process.env.E2E_PORT ?? "4798"; -const DB_PORT = process.env.E2E_DB_PORT ?? "5435"; -const ORIGIN = `http://127.0.0.1:${PORT}`; - -const stubEnv: NodeJS.ProcessEnv = { - ...process.env, - // WorkOS — never contacted during the hydration path; just has to be present. - WORKOS_API_KEY: "sk_e2e_stub", - WORKOS_CLIENT_ID: "client_e2e_stub", - WORKOS_COOKIE_PASSWORD: "e2e_cookie_password_0123456789abcdef0123456789abcdef", - AUTUMN_SECRET_KEY: "am_e2e_stub", - // 32-byte hex at-rest key (only used lazily on secret writes, not on render). - ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - // Direct connection to the throwaway PGlite (no Hyperdrive in dev). - DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${DB_PORT}/postgres`, - EXECUTOR_DIRECT_DATABASE_URL: "true", - CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", - VITE_PUBLIC_SITE_URL: ORIGIN, - MCP_AUTHKIT_DOMAIN: "https://example.com", - MCP_RESOURCE_ORIGIN: ORIGIN, - // Throwaway dev DB on its own port + dir so it never fights a running `bun dev`. - DEV_DB_PORT: DB_PORT, - DEV_DB_PATH: resolve(appDir, ".e2e-db"), -}; - -const children: ChildProcess[] = []; -const start = (cmd: string, args: string[]) => { - const child = spawn(cmd, args, { cwd: appDir, env: stubEnv, stdio: "inherit" }); - child.on("exit", (code) => { - // If either process dies, take the whole harness down so Playwright fails fast. - if (code !== 0 && code !== null) { - shutdown(code); - } - }); - children.push(child); -}; - -const shutdown = (code = 0) => { - for (const child of children) child.kill("SIGTERM"); - process.exit(code); -}; -process.on("SIGINT", () => shutdown(0)); -process.on("SIGTERM", () => shutdown(0)); - -start("bun", ["run", "scripts/dev-db.ts"]); -start("bunx", ["vite", "dev", "--port", PORT, "--strictPort", "--host", "127.0.0.1"]); diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 3d927d01c..cf63ccf83 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.19", + "version": "1.4.22", "private": true, "type": "module", "scripts": { @@ -22,7 +22,6 @@ "test": "node ../../node_modules/vitest/vitest.mjs run && node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts", "test:watch": "node ../../node_modules/vitest/vitest.mjs", "test:node": "node ../../node_modules/vitest/vitest.mjs run --config vitest.node.config.ts", - "test:e2e": "playwright test", "typecheck:slow": "tsc --noEmit" }, "dependencies": { @@ -76,7 +75,6 @@ "@electric-sql/pglite": "^0.4.4", "@electric-sql/pglite-socket": "^0.1.4", "@executor-js/cli": "workspace:*", - "@playwright/test": "^1.60.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", @@ -85,7 +83,6 @@ "concurrently": "^9.2.1", "drizzle-kit": "catalog:", "jiti": "^2.6.1", - "playwright": "^1.60.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "^4.1.5", diff --git a/apps/cloud/playwright.config.ts b/apps/cloud/playwright.config.ts deleted file mode 100644 index ec7a55191..000000000 --- a/apps/cloud/playwright.config.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -// --------------------------------------------------------------------------- -// Playwright e2e for the cloud app. Boots the real Vite dev server (stub env, -// throwaway PGlite — see e2e/e2e-server.ts) and drives it in a real browser, so -// failures that only surface during client hydration (the TanStack Start client -// entry not loading) are caught. The Vitest suites can't see these — they exercise -// the HTTP handler, not the browser module graph. -// --------------------------------------------------------------------------- - -const PORT = 4798; -const BASE_URL = `http://127.0.0.1:${PORT}`; - -export default defineConfig({ - testDir: "./e2e", - testMatch: "**/*.spec.ts", - // One dev server; keep it serial + non-parallel so the assertions are stable. - fullyParallel: false, - workers: 1, - forbidOnly: !!process.env.CI, - retries: 0, - reporter: process.env.CI ? "github" : "list", - timeout: 60_000, - expect: { timeout: 15_000 }, - use: { - baseURL: BASE_URL, - headless: true, - ignoreHTTPSErrors: true, - trace: "retain-on-failure", - }, - projects: [ - { - name: "chromium", - // Drive the system Chrome by default (no Chromium download needed); CI sets - // PLAYWRIGHT_USE_CHROMIUM=1 to use the Playwright-managed browser instead. - use: process.env.PLAYWRIGHT_USE_CHROMIUM - ? { ...devices["Desktop Chrome"] } - : { ...devices["Desktop Chrome"], channel: "chrome" }, - }, - ], - webServer: { - command: "bun run e2e/e2e-server.ts", - url: BASE_URL, - timeout: 120_000, - reuseExistingServer: !process.env.CI, - stdout: "pipe", - stderr: "pipe", - }, -}); diff --git a/apps/cloud/src/api.request-scope.node.test.ts b/apps/cloud/src/api.request-scope.node.test.ts index e7e9e54ca..4a170171c 100644 --- a/apps/cloud/src/api.request-scope.node.test.ts +++ b/apps/cloud/src/api.request-scope.node.test.ts @@ -186,9 +186,10 @@ describe("makeApiLive (prod handler factory) request scoping", () => { // Hit a protected route. ExecutionStackMiddleware short-circuits with // 403 (no session cookie) but not before `requestScopedMiddleware` // has built the per-request layer. We don't care about the response — - // only that the layer was built once per request. - await handler(new Request("http://test.local/scope")); - await handler(new Request("http://test.local/scope")); + // only that the layer was built once per request. `/integrations` is a + // v2 protected route (the old `/scope` group was removed). + await handler(new Request("http://test.local/integrations")); + await handler(new Request("http://test.local/integrations")); expect(counts.acquires).toBe(2); expect(counts.releases).toBe(2); diff --git a/apps/cloud/src/api/secrets-api.node.test.ts b/apps/cloud/src/api/secrets-api.node.test.ts index 28c94efb1..8b6c8283d 100644 --- a/apps/cloud/src/api/secrets-api.node.test.ts +++ b/apps/cloud/src/api/secrets-api.node.test.ts @@ -1,166 +1,196 @@ -// Secrets endpoints — set / list / status / remove round-trip -// and error fidelity within a single org. +// Connection endpoints — create / list / get / remove round-trip and error +// fidelity within a single org (v2). +// +// Ports the v1 "secrets api" suite. In v2 a connection IS the credential: +// owner-scoped, bound 1:1 to an integration, identified by (owner, integration, +// name). There is no scope id and no separate secret value endpoint — the value +// is stored through the connection's provider and never echoed back. import { describe, expect, it } from "@effect/vitest"; import { Effect, Result } from "effect"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + connectionIdentifier, +} from "@executor-js/sdk"; +import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; + +import { asOrg } from "../testing/api-harness"; + +const PingApi = HttpApi.make("connectionsApiTest") + .add( + HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "Connections API Test", version: "1.0.0" })); + +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const canonicalConnectionName = (name: ConnectionName): ConnectionName => + connectionIdentifier(String(name)); + +// Registers a minimal openapi integration so connections have something to bind +// to, then returns its slug. +const registerIntegration = (org: string) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`); + yield* asOrg(org, (client) => + client.openapi.addSpec({ + payload: makeOpenApiHttpApiTestAddSpecPayload(PingApi, { + slug, + baseUrl: "http://example.com", + }), + }), + ); + return slug; + }); -import { ScopeId, SecretId } from "@executor-js/sdk"; - -import { asOrg, fetchForOrg, TEST_BASE_URL } from "../testing/api-harness"; - -describe("secrets api (HTTP)", () => { - it.effect("set → list → status returns secret metadata", () => +describe("connections api (HTTP)", () => { + it.effect("create → list → get returns connection metadata without the value", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const id = `sec_${crypto.randomUUID().slice(0, 8)}`; + const integration = yield* registerIntegration(org); + const name = ConnectionName.make(`conn_${crypto.randomUUID().slice(0, 8)}`); + const storedName = canonicalConnectionName(name); const secretValue = "sk-test-abc"; - const setRef = yield* asOrg(org, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(org) }, - payload: { id: SecretId.make(id), name: "My API Token", value: secretValue }, + const created = yield* asOrg(org, (client) => + client.connections.create({ + payload: { + owner: "org", + name, + integration, + template: TEMPLATE_API_KEY, + identityLabel: "My API Token", + value: secretValue, + }, }), ); - expect(setRef.id).toBe(id); - expect(setRef.scopeId).toBe(org); - expect(JSON.stringify(setRef)).not.toContain(secretValue); + expect(created.name).toBe(storedName); + expect(created.owner).toBe("org"); + expect(JSON.stringify(created)).not.toContain(secretValue); const list = yield* asOrg(org, (client) => - client.secrets.list({ params: { scopeId: ScopeId.make(org) } }), + client.connections.list({ query: { integration } }), ); - expect(list.find((s) => s.id === id)?.name).toBe("My API Token"); + expect(list.find((c) => c.name === storedName)?.identityLabel).toBe("My API Token"); expect(JSON.stringify(list)).not.toContain(secretValue); - const status = yield* asOrg(org, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, - }), + const fetched = yield* asOrg(org, (client) => + client.connections.get({ params: { owner: "org", integration, name: storedName } }), ); - expect(status.status).toBe("resolved"); + expect(fetched.name).toBe(storedName); + expect(fetched.integration).toBe(integration); }), ); - it.effect("resolve is not available through the public API", () => + it.effect("get on an unknown connection fails with ConnectionNotFoundError", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const id = `sec_${crypto.randomUUID().slice(0, 8)}`; + const integration = yield* registerIntegration(org); + const missing = ConnectionName.make(`missing_${crypto.randomUUID().slice(0, 8)}`); - yield* asOrg(org, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(org) }, - payload: { id: SecretId.make(id), name: "n", value: "v" }, - }), - ); - - const response = yield* Effect.promise(() => - fetchForOrg(org)(`${TEST_BASE_URL}/scopes/${org}/secrets/${id}/resolve`), - ); - expect(response.status).toBe(404); - }), - ); - - it.effect("status is resolved for an existing secret, missing for an unknown id", () => - Effect.gen(function* () { - const org = `org_${crypto.randomUUID()}`; - const id = `sec_${crypto.randomUUID().slice(0, 8)}`; - - yield* asOrg(org, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(org) }, - payload: { id: SecretId.make(id), name: "n", value: "v" }, - }), - ); - - const resolvedStatus = yield* asOrg(org, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, - }), - ); - expect(resolvedStatus.status).toBe("resolved"); - - const missingStatus = yield* asOrg(org, (client) => - client.secrets.status({ - params: { - scopeId: ScopeId.make(org), - secretId: SecretId.make(`missing_${crypto.randomUUID().slice(0, 8)}`), - }, - }), + const result = yield* asOrg(org, (client) => + client.connections + .get({ params: { owner: "org", integration, name: missing } }) + .pipe(Effect.result), ); - expect(missingStatus.status).toBe("missing"); + expect(Result.isFailure(result)).toBe(true); }), ); - it.effect("remove deletes the secret; subsequent status is missing and list drops it", () => + it.effect("remove deletes the connection; list drops it and get fails", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const id = `sec_${crypto.randomUUID().slice(0, 8)}`; + const integration = yield* registerIntegration(org); + const name = ConnectionName.make(`conn_${crypto.randomUUID().slice(0, 8)}`); + const storedName = canonicalConnectionName(name); yield* asOrg(org, (client) => Effect.gen(function* () { - yield* client.secrets.set({ - params: { scopeId: ScopeId.make(org) }, - payload: { id: SecretId.make(id), name: "n", value: "v" }, + yield* client.connections.create({ + payload: { owner: "org", name, integration, template: TEMPLATE_API_KEY, value: "v" }, }); - yield* client.secrets.remove({ - params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, + const removed = yield* client.connections.remove({ + params: { owner: "org", integration, name: storedName }, }); + expect(removed.removed).toBe(true); }), ); const list = yield* asOrg(org, (client) => - client.secrets.list({ params: { scopeId: ScopeId.make(org) } }), + client.connections.list({ query: { integration } }), ); - expect(list.map((s) => s.id)).not.toContain(id); + expect(list.map((c) => c.name)).not.toContain(storedName); - const afterStatus = yield* asOrg(org, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, - }), + const afterGet = yield* asOrg(org, (client) => + client.connections + .get({ params: { owner: "org", integration, name: storedName } }) + .pipe(Effect.result), ); - expect(afterStatus.status).toBe("missing"); + expect(Result.isFailure(afterGet)).toBe(true); }), ); - it.effect("remove on an unknown id is a no-op (idempotent)", () => + it.effect("remove on an unknown connection fails with ConnectionNotFoundError", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const missing = `missing_${crypto.randomUUID().slice(0, 8)}`; + const integration = yield* registerIntegration(org); + const missing = ConnectionName.make(`missing_${crypto.randomUUID().slice(0, 8)}`); const result = yield* asOrg(org, (client) => - client.secrets - .remove({ params: { scopeId: ScopeId.make(org), secretId: SecretId.make(missing) } }) + client.connections + .remove({ params: { owner: "org", integration, name: missing } }) .pipe(Effect.result), ); - expect(Result.isSuccess(result)).toBe(true); + expect(Result.isFailure(result)).toBe(true); }), ); - it.effect("set with the same id twice updates the visible metadata", () => + it.effect("create with the same (owner, integration, name) twice updates the metadata", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const id = `sec_${crypto.randomUUID().slice(0, 8)}`; + const integration = yield* registerIntegration(org); + const name = ConnectionName.make(`conn_${crypto.randomUUID().slice(0, 8)}`); + const storedName = canonicalConnectionName(name); const first = yield* asOrg(org, (client) => Effect.gen(function* () { - yield* client.secrets.set({ - params: { scopeId: ScopeId.make(org) }, - payload: { id: SecretId.make(id), name: "first", value: "first-value" }, + yield* client.connections.create({ + payload: { + owner: "org", + name, + integration, + template: TEMPLATE_API_KEY, + identityLabel: "first", + value: "first-value", + }, }); - return yield* client.secrets.list({ params: { scopeId: ScopeId.make(org) } }); + return yield* client.connections.list({ query: { integration } }); }), ); - expect(first.find((s) => s.id === id)?.name).toBe("first"); + expect(first.find((c) => c.name === storedName)?.identityLabel).toBe("first"); const second = yield* asOrg(org, (client) => Effect.gen(function* () { - yield* client.secrets.set({ - params: { scopeId: ScopeId.make(org) }, - payload: { id: SecretId.make(id), name: "updated", value: "second-value" }, + yield* client.connections.create({ + payload: { + owner: "org", + name, + integration, + template: TEMPLATE_API_KEY, + identityLabel: "updated", + value: "second-value", + }, }); - return yield* client.secrets.list({ params: { scopeId: ScopeId.make(org) } }); + return yield* client.connections.list({ query: { integration } }); }), ); - expect(second.find((s) => s.id === id)?.name).toBe("updated"); + expect(second.find((c) => c.name === storedName)?.identityLabel).toBe("updated"); }), ); }); diff --git a/apps/cloud/src/api/sources-api.node.test.ts b/apps/cloud/src/api/sources-api.node.test.ts index 8287ee84f..fa1079642 100644 --- a/apps/cloud/src/api/sources-api.node.test.ts +++ b/apps/cloud/src/api/sources-api.node.test.ts @@ -1,15 +1,20 @@ -// Source endpoints — CRUD through HttpApiClient. Complements tenant -// isolation tests by exercising add → get → update → remove flows and -// the error paths (remove non-existent, remove static, etc.) within a -// single org. +// Integration + connection endpoints — CRUD through HttpApiClient (v2). +// +// Ports the v1 "sources api" suite onto the v2 catalog surface: integrations +// are the tenant-shared catalog (was `sources`), connections are the owner- +// scoped credentials (was `secrets` + credential bindings), and tools are +// per-connection and address-keyed. The plugin extension routes +// (`openapi.addSpec`, `mcp.addServer`, `graphql.addIntegration`) register an +// integration; `connections.create` mints the per-connection tools; execution +// invokes them by their dotted address. import { describe, expect, it } from "@effect/vitest"; -import { Effect, Result, Schema } from "effect"; +import { Effect, Schema } from "effect"; import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { ScopeId, SecretId } from "@executor-js/sdk"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; import { serveGraphqlTestServer, makeGreetingGraphqlSchema, @@ -20,9 +25,8 @@ import { makeOpenApiHttpApiTestSpecPayload, serveOpenApiEchoTestServer, } from "@executor-js/plugin-openapi/testing"; -import { secretsForCredentialTarget } from "@executor-js/react/plugins/secret-header-auth"; -import { asOrg, asUser, testUserOrgScopeId } from "../testing/api-harness"; +import { asOrg, asUser } from "../testing/api-harness"; const isJsonObject = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); @@ -35,17 +39,25 @@ const MinimalSourceApi = HttpApi.make("sourcesApiTest") .add(PingGroup) .annotateMerge(OpenApi.annotations({ title: "Sources API Test", version: "1.0.0" })); -const makeMinimalOpenApiSourcePayload = ( - namespace: string, - options: Omit[1], "namespace"> = {}, +const makeMinimalOpenApiSpecPayload = ( + slug: string, + options: Omit[1], "slug"> = {}, ) => makeOpenApiHttpApiTestAddSpecPayload(MinimalSourceApi, { - namespace, + slug, ...options, }); const makeMinimalOpenApiPreviewPayload = () => makeOpenApiHttpApiTestSpecPayload(MinimalSourceApi); +const randomSlug = (prefix: string) => + IntegrationSlug.make(`${prefix}_${crypto.randomUUID().replace(/-/g, "_")}`); + +const NAME_MAIN = ConnectionName.make("main"); +const NAME_PERSONAL = ConnectionName.make("personal"); +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const TEMPLATE_NONE = AuthTemplateSlug.make("none"); + // The Cloudflare OpenAPI spec is the biggest real spec we care about: // 16MB, 2700+ operations, thousands of shared schemas. Exercising // addSpec end-to-end on it through the real Drizzle/FumaDB path is the @@ -58,47 +70,41 @@ const CLOUDFLARE_SPEC_PATH = resolve( ); const CLOUDFLARE_SPEC = readFileSync(CLOUDFLARE_SPEC_PATH, "utf-8"); -describe("sources api (HTTP)", () => { - it.effect("addSpec → sources.list includes the new namespace", () => +describe("integrations api (HTTP)", () => { + it.effect("addSpec → integrations.list includes the new slug", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = randomSlug("ns"); yield* asOrg(org, (client) => Effect.gen(function* () { const result = yield* client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, - payload: makeMinimalOpenApiSourcePayload(namespace), + payload: makeMinimalOpenApiSpecPayload(slug), }); - expect(result.namespace).toBe(namespace); + expect(result.slug).toBe(slug); expect(result.toolCount).toBeGreaterThan(0); }), ); - const sources = yield* asOrg(org, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(org) } }), - ); - expect(sources.map((s) => s.id)).toContain(namespace); + const integrations = yield* asOrg(org, (client) => client.integrations.list({})); + expect(integrations.map((s) => s.slug)).toContain(slug); }), ); - it.effect("openapi.getSource returns the stored source after addSpec", () => + it.effect("openapi.getIntegration returns the stored integration after addSpec", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = randomSlug("ns"); yield* asOrg(org, (client) => - client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, - payload: makeMinimalOpenApiSourcePayload(namespace), - }), + client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) }), ); const fetched = yield* asOrg(org, (client) => - client.openapi.getSource({ params: { scopeId: ScopeId.make(org), namespace } }), + client.openapi.getIntegration({ params: { slug } }), ); expect(fetched).not.toBeNull(); - expect(fetched?.namespace).toBe(namespace); + expect(fetched?.slug).toBe(slug); }), ); @@ -106,10 +112,7 @@ describe("sources api (HTTP)", () => { Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; const preview = yield* asOrg(org, (client) => - client.openapi.previewSpec({ - params: { scopeId: ScopeId.make(org) }, - payload: makeMinimalOpenApiPreviewPayload(), - }), + client.openapi.previewSpec({ payload: makeMinimalOpenApiPreviewPayload() }), ); expect(preview).toMatchObject({ @@ -131,8 +134,7 @@ describe("sources api (HTTP)", () => { const result = yield* asOrg(org, (client) => client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, - payload: makeMinimalOpenApiSourcePayload(`ns_${crypto.randomUUID().replace(/-/g, "_")}`, { + payload: makeMinimalOpenApiSpecPayload(randomSlug("ns"), { baseUrl: "http://example.com", }), }), @@ -142,7 +144,7 @@ describe("sources api (HTTP)", () => { }), ); - it.effect("added OpenAPI source can be listed, inspected, and invoked through execution", () => + it.effect("added OpenAPI integration can be connected, listed, and invoked via execution", () => Effect.gen(function* () { const server = yield* serveOpenApiEchoTestServer({ transformSpec: (spec) => ({ @@ -154,42 +156,49 @@ describe("sources api (HTTP)", () => { }), }); const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; - const scopeId = ScopeId.make(org); + const slug = randomSlug("ns"); const addResult = yield* asOrg(org, (client) => client.openapi.addSpec({ - params: { scopeId }, payload: { spec: { kind: "blob", value: server.specJson }, - name: "Invocable Source API", + slug, + description: "Invocable Source API", baseUrl: server.baseUrl, - namespace, }, }), ); - expect(addResult).toEqual({ namespace, toolCount: 1 }); + expect(addResult.slug).toBe(slug); const fetched = yield* asOrg(org, (client) => - client.openapi.getSource({ params: { scopeId, namespace } }), + client.openapi.getIntegration({ params: { slug } }), + ); + expect(fetched).toMatchObject({ slug, kind: "openapi" }); + + // Mint a connection so the per-connection tools are stamped + persisted. + yield* asOrg(org, (client) => + client.connections.create({ + payload: { + owner: "org", + name: NAME_MAIN, + integration: slug, + template: TEMPLATE_API_KEY, + value: "static-token", + }, + }), ); - expect(fetched).toMatchObject({ - namespace, - name: "Invocable Source API", - config: { baseUrl: server.baseUrl }, - }); const tools = yield* asOrg(org, (client) => - client.sources.tools({ params: { scopeId, sourceId: namespace } }), + client.tools.list({ query: { integration: slug } }), ); - const toolId = `${namespace}.echo.echoMessage`; - expect(tools.map((tool) => tool.id)).toContain(toolId); + const toolAddress = `tools.${slug}.org.main.echo.echoMessage`; + expect(tools.map((tool) => tool.address)).toContain(toolAddress); const execution = yield* asOrg(org, (client) => client.executions.execute({ payload: { code: [ - `const result = await tools.${namespace}.echo.echoMessage({ message: "hello", suffix: "world" });`, + `const result = await ${toolAddress}({ message: "hello", suffix: "world" });`, "return result;", ].join("\n"), }, @@ -200,7 +209,6 @@ describe("sources api (HTTP)", () => { if (execution.status !== "completed") return; expect(execution.isError).toBe(false); expect(execution.structured).toMatchObject({ - status: "completed", result: { ok: true, data: { @@ -212,7 +220,6 @@ describe("sources api (HTTP)", () => { }, }, }, - logs: [], }); expect(yield* server.requests).toContainEqual( expect.objectContaining({ path: "/echo/hello" }), @@ -220,91 +227,82 @@ describe("sources api (HTTP)", () => { }), ); - it.effect("mcp.getSource returns a persisted source even when discovery failed", () => + it.effect("mcp.addServer persists the integration without dialing (discovery is deferred)", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `mcp_${crypto.randomUUID().replace(/-/g, "_")}`; - const scopeId = ScopeId.make(org); + const slug = randomSlug("mcp"); + // v2: addServer only registers the integration catalog row — it does NOT + // dial the server (discovery happens later at connection time). So a dead + // endpoint still registers successfully and getServer returns the row. const addResult = yield* asOrg(org, (client) => - client.mcp - .addSource({ - params: { scopeId }, - payload: { - transport: "remote", - name: "Broken MCP", - endpoint: "http://127.0.0.1:1/mcp", - remoteTransport: "auto", - namespace, - }, - }) - .pipe(Effect.result), + client.mcp.addServer({ + payload: { + transport: "remote", + name: "Broken MCP", + endpoint: "http://127.0.0.1:1/mcp", + remoteTransport: "auto", + slug, + }, + }), ); - expect(Result.isFailure(addResult)).toBe(true); + expect(addResult.slug).toBe(slug); - const fetched = yield* asOrg(org, (client) => - client.mcp.getSource({ params: { scopeId, namespace } }), - ); + const fetched = yield* asOrg(org, (client) => client.mcp.getServer({ params: { slug } })); expect(fetched).toMatchObject({ - namespace, - name: "Broken MCP", + slug, config: { transport: "remote", endpoint: "http://127.0.0.1:1/mcp", remoteTransport: "auto", }, }); - - const tools = yield* asOrg(org, (client) => - client.sources.tools({ params: { scopeId, sourceId: namespace } }), - ); - expect(tools).toEqual([]); }), ); - it.effect("added GraphQL source can be inspected and invoked through execution", () => + it.effect("added GraphQL integration can be inspected and invoked through execution", () => Effect.gen(function* () { const server = yield* serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema({ includeMutation: false }), }); const org = `org_${crypto.randomUUID()}`; - const namespace = `gql_${crypto.randomUUID().replace(/-/g, "_")}`; - const scopeId = ScopeId.make(org); + const slug = randomSlug("gql"); const added = yield* asOrg(org, (client) => - client.graphql.addSource({ - params: { scopeId }, + client.graphql.addIntegration({ payload: { endpoint: server.endpoint, - namespace, + slug, name: "Cloud GraphQL", }, }), ); - expect(added).toEqual({ namespace, toolCount: 1 }); + expect(added.slug).toBe(slug); - const fetched = yield* asOrg(org, (client) => - client.graphql.getSource({ params: { scopeId, namespace } }), + yield* asOrg(org, (client) => + client.connections.create({ + payload: { + owner: "org", + name: NAME_MAIN, + integration: slug, + template: TEMPLATE_NONE, + value: "unused", + }, + }), ); - expect(fetched).toMatchObject({ - namespace, - name: "Cloud GraphQL", - endpoint: server.endpoint, - }); const tools = yield* asOrg(org, (client) => - client.sources.tools({ params: { scopeId, sourceId: namespace } }), + client.tools.list({ query: { integration: slug } }), ); - const toolId = `${namespace}.query.hello`; - expect(tools.map((tool) => tool.id)).toContain(toolId); + const toolAddress = `tools.${slug}.org.main.query.hello`; + expect(tools.map((tool) => tool.address)).toContain(toolAddress); const execution = yield* asOrg(org, (client) => client.executions.execute({ payload: { - code: [ - `const result = await tools.${namespace}.query.hello({ name: "Ada" });`, - "return result;", - ].join("\n"), + code: [`const result = await ${toolAddress}({ name: "Ada" });`, "return result;"].join( + "\n", + ), }, }), ); @@ -313,7 +311,6 @@ describe("sources api (HTTP)", () => { if (execution.status !== "completed") return; expect(execution.isError).toBe(false); expect(execution.structured).toMatchObject({ - status: "completed", result: { ok: true, data: { hello: "Hello Ada" } }, }); const requests = yield* server.requests; @@ -326,67 +323,7 @@ describe("sources api (HTTP)", () => { }), ); - it.effect( - "GraphQL add accepts a user-scoped bearer credential for org source introspection", - () => - Effect.gen(function* () { - const server = yield* serveGraphqlTestServer({ - schema: makeGreetingGraphqlSchema({ includeMutation: false }), - auth: { - validateAuthorization: (authorization) => - Effect.succeed(authorization === "Bearer github-token"), - }, - }); - const organizationId = `org_${crypto.randomUUID()}`; - const userId = `user_${crypto.randomUUID()}`; - const userScope = testUserOrgScopeId(userId, organizationId); - const namespace = `github_graphql_${crypto.randomUUID().replace(/-/g, "_")}`; - - yield* asUser(userId, organizationId, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(userScope) }, - payload: { - id: SecretId.make("github-graphql-authorization"), - name: "Github GraphQL Authorization", - value: "github-token", - }, - }), - ); - - const added = yield* asUser(userId, organizationId, (client) => - client.graphql.addSource({ - params: { scopeId: ScopeId.make(organizationId) }, - payload: { - endpoint: server.endpoint, - namespace, - name: "Github GraphQL", - headers: { - Authorization: { kind: "secret", prefix: "Bearer " }, - }, - credentials: { - scope: ScopeId.make(userScope), - headers: { - Authorization: { - kind: "secret", - secretId: "github-graphql-authorization", - secretScope: userScope, - prefix: "Bearer ", - }, - }, - }, - }, - }), - ); - - expect(added).toEqual({ namespace, toolCount: 1 }); - const requests = yield* server.requests; - expect( - requests.some((request) => request.headers.authorization === "Bearer github-token"), - ).toBe(true); - }), - ); - - it.effect("added MCP source can be inspected and invoked through execution", () => + it.effect("added MCP integration can be inspected and invoked through execution", () => Effect.gen(function* () { const server = yield* serveMcpServer(() => makeGreetingMcpServer({ @@ -396,29 +333,24 @@ describe("sources api (HTTP)", () => { }), ); const org = `org_${crypto.randomUUID()}`; - const namespace = `mcp_${crypto.randomUUID().replace(/-/g, "_")}`; - const scopeId = ScopeId.make(org); + const slug = randomSlug("mcp"); const added = yield* asOrg(org, (client) => - client.mcp.addSource({ - params: { scopeId }, + client.mcp.addServer({ payload: { transport: "remote", name: "Cloud MCP", endpoint: server.endpoint, remoteTransport: "streamable-http", - namespace, + slug, }, }), ); - expect(added).toEqual({ namespace, toolCount: 1 }); + expect(added.slug).toBe(slug); - const fetched = yield* asOrg(org, (client) => - client.mcp.getSource({ params: { scopeId, namespace } }), - ); + const fetched = yield* asOrg(org, (client) => client.mcp.getServer({ params: { slug } })); expect(fetched).toMatchObject({ - namespace, - name: "Cloud MCP", + slug, config: { transport: "remote", endpoint: server.endpoint, @@ -426,19 +358,28 @@ describe("sources api (HTTP)", () => { }, }); + yield* asOrg(org, (client) => + client.connections.create({ + payload: { + owner: "org", + name: NAME_MAIN, + integration: slug, + template: TEMPLATE_NONE, + value: "unused", + }, + }), + ); + const tools = yield* asOrg(org, (client) => - client.sources.tools({ params: { scopeId, sourceId: namespace } }), + client.tools.list({ query: { integration: slug } }), ); - const toolId = `${namespace}.simple_echo`; - expect(tools.map((tool) => tool.id)).toContain(toolId); + const toolAddress = `tools.${slug}.org.main.simple_echo`; + expect(tools.map((tool) => tool.address)).toContain(toolAddress); const execution = yield* asOrg(org, (client) => client.executions.execute({ payload: { - code: [ - `const result = await tools.${namespace}.simple_echo({});`, - "return result;", - ].join("\n"), + code: [`const result = await ${toolAddress}({});`, "return result;"].join("\n"), }, }), ); @@ -447,7 +388,6 @@ describe("sources api (HTTP)", () => { if (execution.status !== "completed") return; expect(execution.isError).toBe(false); expect(execution.structured).toMatchObject({ - status: "completed", result: { ok: true, data: { content: [{ type: "text", text: "cloud-mcp-ok" }] }, @@ -457,7 +397,7 @@ describe("sources api (HTTP)", () => { }), ); - it.effect("generic source refresh updates MCP source tool rows", () => + it.effect("connection refresh updates MCP per-connection tool rows", () => Effect.gen(function* () { let toolName = "before_refresh"; const server = yield* serveMcpServer(() => @@ -468,338 +408,142 @@ describe("sources api (HTTP)", () => { }), ); const org = `org_${crypto.randomUUID()}`; - const namespace = `mcp_${crypto.randomUUID().replace(/-/g, "_")}`; - const scopeId = ScopeId.make(org); + const slug = randomSlug("mcp"); - const added = yield* asOrg(org, (client) => - client.mcp.addSource({ - params: { scopeId }, + yield* asOrg(org, (client) => + client.mcp.addServer({ payload: { transport: "remote", name: "Cloud Refresh MCP", endpoint: server.endpoint, remoteTransport: "streamable-http", - namespace, + slug, + }, + }), + ); + + yield* asOrg(org, (client) => + client.connections.create({ + payload: { + owner: "org", + name: NAME_MAIN, + integration: slug, + template: TEMPLATE_NONE, + value: "unused", }, }), ); - expect(added).toEqual({ namespace, toolCount: 1 }); const beforeTools = yield* asOrg(org, (client) => - client.sources.tools({ params: { scopeId, sourceId: namespace } }), + client.tools.list({ query: { integration: slug } }), + ); + expect(beforeTools.map((tool) => tool.address)).toContain( + `tools.${slug}.org.main.before_refresh`, ); - expect(beforeTools.map((tool) => tool.id)).toContain(`${namespace}.before_refresh`); - expect(beforeTools.map((tool) => tool.id)).not.toContain(`${namespace}.after_refresh`); toolName = "after_refresh"; - const refreshResult = yield* asOrg(org, (client) => - client.sources.refresh({ params: { scopeId, sourceId: namespace } }), + yield* asOrg(org, (client) => + client.connections.refresh({ + params: { owner: "org", integration: slug, name: NAME_MAIN }, + }), ); - expect(refreshResult.refreshed).toBe(true); const afterTools = yield* asOrg(org, (client) => - client.sources.tools({ params: { scopeId, sourceId: namespace } }), + client.tools.list({ query: { integration: slug } }), + ); + expect(afterTools.map((tool) => tool.address)).not.toContain( + `tools.${slug}.org.main.before_refresh`, + ); + expect(afterTools.map((tool) => tool.address)).toContain( + `tools.${slug}.org.main.after_refresh`, ); - expect(afterTools.map((tool) => tool.id)).not.toContain(`${namespace}.before_refresh`); - expect(afterTools.map((tool) => tool.id)).toContain(`${namespace}.after_refresh`); }), ); - it.effect("sources.remove deletes the source and it drops off sources.list", () => + it.effect("integrations.remove deletes the integration and drops off the list", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = randomSlug("ns"); yield* asOrg(org, (client) => Effect.gen(function* () { - yield* client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, - payload: makeMinimalOpenApiSourcePayload(namespace), - }); - yield* client.sources.remove({ - params: { scopeId: ScopeId.make(org), sourceId: namespace }, - }); + yield* client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) }); + yield* client.integrations.remove({ params: { slug } }); }), ); - const after = yield* asOrg(org, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(org) } }), - ); - expect(after.map((s) => s.id)).not.toContain(namespace); - }), - ); - - it.effect("sources.remove on a non-existent sourceId is a no-op (idempotent)", () => - Effect.gen(function* () { - const org = `org_${crypto.randomUUID()}`; - const ghost = `missing_${crypto.randomUUID().slice(0, 8)}`; - - const result = yield* asOrg(org, (client) => - client.sources - .remove({ params: { scopeId: ScopeId.make(org), sourceId: ghost } }) - .pipe(Effect.result), - ); - expect(Result.isSuccess(result)).toBe(true); - }), - ); - - it.effect("sources.remove on a static source is rejected", () => - Effect.gen(function* () { - // `canRemove: false` is reserved for static (plugin-declared) - // sources. Plugin-owned executor tools are mounted under the - // built-in executor source. - const org = `org_${crypto.randomUUID()}`; - - const result = yield* asOrg(org, (client) => - client.sources - .remove({ params: { scopeId: ScopeId.make(org), sourceId: "executor" } }) - .pipe(Effect.result), - ); - expect(Result.isFailure(result)).toBe(true); + const after = yield* asOrg(org, (client) => client.integrations.list({})); + expect(after.map((s) => s.slug)).not.toContain(slug); }), ); - it.effect("sources.configure round-trips OpenAPI baseUrl + name changes", () => + it.effect("integrations.update round-trips a description change", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = randomSlug("ns"); yield* asOrg(org, (client) => Effect.gen(function* () { - yield* client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, - payload: makeMinimalOpenApiSourcePayload(namespace), - }); - yield* client.sources.configure({ - params: { scopeId: ScopeId.make(org) }, - payload: { - source: { id: namespace, scope: ScopeId.make(org) }, - scope: ScopeId.make(org), - type: "openapi", - config: { - scope: org, - name: "Renamed API", - baseUrl: "https://override.example.com", - }, - }, + yield* client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) }); + yield* client.integrations.update({ + params: { slug }, + payload: { description: "Renamed API" }, }); }), ); - const fetched = yield* asOrg(org, (client) => - client.openapi.getSource({ params: { scopeId: ScopeId.make(org), namespace } }), - ); - expect(fetched?.name).toBe("Renamed API"); - expect(fetched?.config.baseUrl).toBe("https://override.example.com"); + const fetched = yield* asOrg(org, (client) => client.integrations.get({ params: { slug } })); + expect(fetched?.description).toBe("Renamed API"); }), ); - it.effect("per-user source bindings isolate personal credentials over HTTP", () => + it.effect("org + user connections produce distinct addresses with isolated values", () => Effect.gen(function* () { const organizationId = `org_${crypto.randomUUID()}`; const aliceId = `user_${crypto.randomUUID().slice(0, 8)}`; const bobId = `user_${crypto.randomUUID().slice(0, 8)}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; - const aliceScope = testUserOrgScopeId(aliceId, organizationId); - const bobScope = testUserOrgScopeId(bobId, organizationId); + const slug = randomSlug("ns"); yield* asOrg(organizationId, (client) => - client.openapi.addSpec({ - params: { scopeId: ScopeId.make(organizationId) }, - payload: { - ...makeMinimalOpenApiSourcePayload(namespace), - headers: { - Authorization: { - kind: "secret", - prefix: "Bearer ", - }, - }, - }, - }), + client.openapi.addSpec({ payload: makeMinimalOpenApiSpecPayload(slug) }), ); + // Alice's personal (`owner: "user"`) connection. yield* asUser(aliceId, organizationId, (client) => - Effect.gen(function* () { - yield* client.secrets.set({ - params: { scopeId: ScopeId.make(aliceScope) }, - payload: { - id: SecretId.make("alice_pat"), - name: "Alice PAT", - value: "alice-secret", - }, - }); - const binding = yield* client.sources.setBinding({ - params: { scopeId: ScopeId.make(aliceScope) }, - payload: { - scope: ScopeId.make(aliceScope), - source: { id: namespace, scope: ScopeId.make(organizationId) }, - slotKey: "header:authorization", - value: { - kind: "secret", - secretId: SecretId.make("alice_pat"), - }, - }, - }); - expect(binding).toMatchObject({ - sourceId: namespace, - sourceScopeId: ScopeId.make(organizationId), - scopeId: ScopeId.make(aliceScope), - slotKey: "header:authorization", - value: { - kind: "secret", - secretId: SecretId.make("alice_pat"), - }, - }); - expect(binding.createdAt).toBeInstanceOf(Date); - expect(binding.updatedAt).toBeInstanceOf(Date); + client.connections.create({ + payload: { + owner: "user", + name: NAME_PERSONAL, + integration: slug, + template: TEMPLATE_API_KEY, + value: "alice-secret", + }, }), ); + // Bob's personal connection under the same org + integration. yield* asUser(bobId, organizationId, (client) => - Effect.gen(function* () { - yield* client.secrets.set({ - params: { scopeId: ScopeId.make(bobScope) }, - payload: { - id: SecretId.make("bob_pat"), - name: "Bob PAT", - value: "bob-secret", - }, - }); - yield* client.sources.setBinding({ - params: { scopeId: ScopeId.make(bobScope) }, - payload: { - scope: ScopeId.make(bobScope), - source: { id: namespace, scope: ScopeId.make(organizationId) }, - slotKey: "header:authorization", - value: { - kind: "secret", - secretId: SecretId.make("bob_pat"), - }, - }, - }); - }), - ); - - const aliceBindings = yield* asUser(aliceId, organizationId, (client) => - client.sources.listBindings({ - params: { - scopeId: ScopeId.make(aliceScope), - sourceId: namespace, - sourceScopeId: ScopeId.make(organizationId), - }, - }), - ); - expect(aliceBindings).toContainEqual( - expect.objectContaining({ - scopeId: ScopeId.make(aliceScope), - slotKey: "header:authorization", - value: { - kind: "secret", - secretId: SecretId.make("alice_pat"), - secretScopeId: ScopeId.make(aliceScope), - }, - }), - ); - expect( - aliceBindings.some( - (binding) => - binding.slotKey === "header:authorization" && - binding.value.kind === "secret" && - binding.value.secretId === SecretId.make("bob_pat"), - ), - ).toBe(false); - - const bobBindings = yield* asUser(bobId, organizationId, (client) => - client.sources.listBindings({ - params: { - scopeId: ScopeId.make(bobScope), - sourceId: namespace, - sourceScopeId: ScopeId.make(organizationId), - }, - }), - ); - expect(bobBindings).toContainEqual( - expect.objectContaining({ - scopeId: ScopeId.make(bobScope), - slotKey: "header:authorization", - value: { - kind: "secret", - secretId: SecretId.make("bob_pat"), - secretScopeId: ScopeId.make(bobScope), + client.connections.create({ + payload: { + owner: "user", + name: NAME_PERSONAL, + integration: slug, + template: TEMPLATE_API_KEY, + value: "bob-secret", }, }), ); - expect( - bobBindings.some( - (binding) => - binding.slotKey === "header:authorization" && - binding.value.kind === "secret" && - binding.value.secretId === SecretId.make("alice_pat"), - ), - ).toBe(false); - const sources = yield* asOrg(organizationId, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(organizationId) } }), - ); - expect(sources.find((source) => source.id === namespace)?.scopeId).toBe( - ScopeId.make(organizationId), + // Each user sees only their own user-owned connection (no shadowing). + const aliceConnections = yield* asUser(aliceId, organizationId, (client) => + client.connections.list({ query: { integration: slug, owner: "user" } }), ); - }), - ); + expect(aliceConnections.map((c) => c.owner)).toEqual(["user"]); - it.effect("personal source override picker can see org-owned secrets over HTTP", () => - Effect.gen(function* () { - const organizationId = `org_${crypto.randomUUID()}`; - const aliceId = `user_${crypto.randomUUID().slice(0, 8)}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; - const aliceScope = testUserOrgScopeId(aliceId, organizationId); - - yield* asOrg(organizationId, (client) => - Effect.gen(function* () { - yield* client.openapi.addSpec({ - params: { scopeId: ScopeId.make(organizationId) }, - payload: { - ...makeMinimalOpenApiSourcePayload(namespace), - headers: { - Authorization: { - kind: "secret", - prefix: "Bearer ", - }, - }, - }, - }); - - yield* client.secrets.set({ - params: { scopeId: ScopeId.make(organizationId) }, - payload: { - id: SecretId.make("shared_pat"), - name: "Shared PAT", - value: "org-secret", - }, - }); - }), - ); - - const secrets = yield* asUser(aliceId, organizationId, (client) => - client.secrets.listAll({ params: { scopeId: ScopeId.make(aliceScope) } }), - ); - - const pickerSecrets = secrets.map((secret) => ({ - id: String(secret.id), - scopeId: String(secret.scopeId), - name: secret.name, - provider: secret.provider ? String(secret.provider) : undefined, - })); - - expect(pickerSecrets).toContainEqual( - expect.objectContaining({ id: "shared_pat", scopeId: organizationId }), + const bobConnections = yield* asUser(bobId, organizationId, (client) => + client.connections.list({ query: { integration: slug, owner: "user" } }), ); - expect( - secretsForCredentialTarget(pickerSecrets, ScopeId.make(aliceScope), [ - { id: ScopeId.make(aliceScope) }, - { id: ScopeId.make(organizationId) }, - ]).map((secret) => secret.id), - ).toContain("shared_pat"); + expect(bobConnections.map((c) => c.owner)).toEqual(["user"]); }), ); @@ -808,39 +552,30 @@ describe("sources api (HTTP)", () => { () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = randomSlug("ns"); const result = yield* asOrg(org, (client) => client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, payload: { spec: { kind: "blob", value: CLOUDFLARE_SPEC }, - name: namespace, + slug, + description: slug, baseUrl: "https://api.cloudflare.com/client/v4", - namespace, }, }), ); - expect(result.namespace).toBe(namespace); + expect(result.slug).toBe(slug); expect(result.toolCount).toBeGreaterThan(1000); - const sources = yield* asOrg(org, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(org) } }), - ); - expect(sources.map((s) => s.id)).toContain(namespace); + const integrations = yield* asOrg(org, (client) => client.integrations.list({})); + expect(integrations.map((s) => s.slug)).toContain(slug); // removeSpec on the same size must also land cleanly — catches // symmetrical regressions on the delete side (e.g. deleteMany // fanning out to per-row deletes). - yield* asOrg(org, (client) => - client.sources.remove({ - params: { scopeId: ScopeId.make(org), sourceId: namespace }, - }), - ); - const after = yield* asOrg(org, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(org) } }), - ); - expect(after.map((s) => s.id)).not.toContain(namespace); + yield* asOrg(org, (client) => client.integrations.remove({ params: { slug } })); + const after = yield* asOrg(org, (client) => client.integrations.list({})); + expect(after.map((s) => s.slug)).not.toContain(slug); }), // 60s is generous for a correct O(1) write path on local PGlite; // a per-row regression would take minutes and hit this ceiling diff --git a/apps/cloud/src/api/sources-refresh.node.test.ts b/apps/cloud/src/api/sources-refresh.node.test.ts index 63e2d68b4..1aa45a009 100644 --- a/apps/cloud/src/api/sources-refresh.node.test.ts +++ b/apps/cloud/src/api/sources-refresh.node.test.ts @@ -1,136 +1,138 @@ -// Refresh endpoint — covers `sources.refresh(id)` for an OpenAPI -// source added from a URL. Stands up a local HTTP server that serves -// one of two spec versions (swappable mid-test) so we can verify the -// refresh path re-fetches from the stored origin and replaces the -// operation set. Raw-text sources assert the no-op branch. +// Connection refresh endpoint — covers `connections.refresh(ref)` (v2). +// +// In v2 tools are produced per-connection by the owning plugin's `resolveTools`. +// `connections.refresh` re-runs that hook: for MCP it re-dials the live server +// (so a server-side tool change is picked up), and the integration's +// `canRefresh` flag reflects whether the catalog row can be refreshed at all +// (openapi-from-URL → true; openapi-from-blob → false). import { describe, expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; -import { ScopeId } from "@executor-js/sdk"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; import { makeOpenApiHttpApiTestSpecPayload, serveMutableOpenApiSpecTestServer, } from "@executor-js/plugin-openapi/testing"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; import { asOrg } from "../testing/api-harness"; const PingEndpoint = HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }); -const PongEndpoint = HttpApiEndpoint.get("pong", "/pong", { success: Schema.Unknown }); -const RefreshGroupV1 = HttpApiGroup.make("default", { topLevel: true }).add(PingEndpoint); -const RefreshGroupV2 = HttpApiGroup.make("default", { topLevel: true }) - .add(PingEndpoint) - .add(PongEndpoint); +const RefreshApi = HttpApi.make("refreshFixture") + .add(HttpApiGroup.make("default", { topLevel: true }).add(PingEndpoint)) + .annotateMerge(OpenApi.annotations({ title: "Refresh Fixture", version: "1.0.0" })); -const refreshApi = (version: "1.0.0" | "2.0.0") => - HttpApi.make("refreshFixture") - .add(version === "1.0.0" ? RefreshGroupV1 : RefreshGroupV2) - .annotateMerge(OpenApi.annotations({ title: "Refresh Fixture", version })); +const makeRefreshSpecText = () => makeOpenApiHttpApiTestSpecPayload(RefreshApi).spec; -const makeRefreshSpecText = () => makeOpenApiHttpApiTestSpecPayload(refreshApi("1.0.0")).spec; +const NAME_MAIN = ConnectionName.make("main"); +const TEMPLATE_NONE = AuthTemplateSlug.make("none"); -describe("sources.refresh (HTTP)", () => { - it.effect("addSpec from URL → canRefresh:true; refresh re-fetches and updates tools", () => +describe("connections.refresh (HTTP)", () => { + it.effect("refresh re-dials the MCP server and updates per-connection tools", () => Effect.scoped( Effect.gen(function* () { - const server = yield* serveMutableOpenApiSpecTestServer({ - initialApi: refreshApi("1.0.0"), - }); + let toolName = "before_refresh"; + const server = yield* serveMcpServer(() => + makeGreetingMcpServer({ name: "refresh-mcp", toolName, text: "ok" }), + ); const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = IntegrationSlug.make(`mcp_${crypto.randomUUID().replace(/-/g, "_")}`); yield* asOrg(org, (client) => - client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, + client.mcp.addServer({ payload: { - spec: { kind: "url", url: server.specUrl }, - name: namespace, - baseUrl: server.baseUrl, - namespace, + transport: "remote", + name: "Refresh MCP", + endpoint: server.endpoint, + remoteTransport: "streamable-http", + slug, }, }), ); - const before = yield* asOrg(org, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(org) } }), + yield* asOrg(org, (client) => + client.connections.create({ + payload: { + owner: "org", + name: NAME_MAIN, + integration: slug, + template: TEMPLATE_NONE, + value: "unused", + }, + }), ); - const beforeSource = before.find((s) => s.id === namespace); - expect(beforeSource?.canRefresh).toBe(true); - const fetchedBefore = yield* asOrg(org, (client) => - client.openapi.getSource({ - params: { scopeId: ScopeId.make(org), namespace }, + const before = yield* asOrg(org, (client) => + client.tools.list({ query: { integration: slug } }), + ); + expect(before.map((t) => t.address)).toContain(`tools.${slug}.org.main.before_refresh`); + expect(before.map((t) => t.address)).not.toContain(`tools.${slug}.org.main.after_refresh`); + + // Flip the live server's tool name and refresh the connection. + toolName = "after_refresh"; + const refreshed = yield* asOrg(org, (client) => + client.connections.refresh({ + params: { owner: "org", integration: slug, name: NAME_MAIN }, }), ); - expect(fetchedBefore?.config.sourceUrl).toBe(server.specUrl); + expect(refreshed.some((t) => t.address.endsWith(".after_refresh"))).toBe(true); - const beforeTools = yield* asOrg(org, (client) => - client.sources.tools({ - params: { scopeId: ScopeId.make(org), sourceId: namespace }, - }), + const after = yield* asOrg(org, (client) => + client.tools.list({ query: { integration: slug } }), ); - expect(beforeTools.length).toBe(1); - expect(beforeTools.some((t) => t.id.endsWith(".default.ping"))).toBe(true); - expect(beforeTools.some((t) => t.id.endsWith(".default.pong"))).toBe(false); + expect(after.map((t) => t.address)).not.toContain(`tools.${slug}.org.main.before_refresh`); + expect(after.map((t) => t.address)).toContain(`tools.${slug}.org.main.after_refresh`); + }), + ), + ); - // Flip the remote to v2 (adds `pong`) and trigger refresh. - yield* server.setApi(refreshApi("2.0.0")); - const requestsBefore = yield* server.requestCount; + it.effect("openapi-from-URL integration reports canRefresh:true", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMutableOpenApiSpecTestServer({ initialApi: RefreshApi }); + const org = `org_${crypto.randomUUID()}`; + const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`); - const refreshResult = yield* asOrg(org, (client) => - client.sources.refresh({ - params: { scopeId: ScopeId.make(org), sourceId: namespace }, + yield* asOrg(org, (client) => + client.openapi.addSpec({ + payload: { + spec: { kind: "url", url: server.specUrl }, + slug, + baseUrl: server.baseUrl, + }, }), ); - expect(refreshResult.refreshed).toBe(true); - expect(yield* server.requestCount).toBeGreaterThan(requestsBefore); - const afterTools = yield* asOrg(org, (client) => - client.sources.tools({ - params: { scopeId: ScopeId.make(org), sourceId: namespace }, - }), + const integration = yield* asOrg(org, (client) => + client.integrations.get({ params: { slug } }), ); - expect(afterTools.length).toBe(2); - expect(afterTools.some((t) => t.id.endsWith(".default.ping"))).toBe(true); - expect(afterTools.some((t) => t.id.endsWith(".default.pong"))).toBe(true); + expect(integration?.canRefresh).toBe(true); }), ), ); - it.effect("addSpec from raw text → canRefresh:false; refresh is a no-op", () => + it.effect("openapi-from-blob integration reports canRefresh:false", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`); yield* asOrg(org, (client) => client.openapi.addSpec({ - params: { scopeId: ScopeId.make(org) }, payload: { spec: { kind: "blob", value: makeRefreshSpecText() }, - name: namespace, + slug, baseUrl: "https://api.example.test", - namespace, }, }), ); - const sources = yield* asOrg(org, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(org) } }), - ); - const row = sources.find((s) => s.id === namespace); - expect(row?.canRefresh).toBe(false); - - // Raw-text sources reach the plugin with no stored URL and - // silently no-op — UI gates the action on canRefresh, but the - // server should not 500 if a caller slips through. - const result = yield* asOrg(org, (client) => - client.sources.refresh({ - params: { scopeId: ScopeId.make(org), sourceId: namespace }, - }), + const integration = yield* asOrg(org, (client) => + client.integrations.get({ params: { slug } }), ); - expect(result.refreshed).toBe(true); + expect(integration?.canRefresh).toBe(false); }), ); }); diff --git a/apps/cloud/src/api/tenant-isolation.node.test.ts b/apps/cloud/src/api/tenant-isolation.node.test.ts index da2e5b8d2..464d75533 100644 --- a/apps/cloud/src/api/tenant-isolation.node.test.ts +++ b/apps/cloud/src/api/tenant-isolation.node.test.ts @@ -1,12 +1,18 @@ -// Tenant isolation integration test. Runs in plain node (not workerd) +// Tenant isolation integration test (v2). Runs in plain node (not workerd) // via vitest.node.config.ts — workerd's dev-mode compile stack crashes // on the full cloud module graph. +// +// In v2 the per-request executor binds `{ tenant: organizationId, subject: +// accountId }` from auth; there is no scopeId path param, so a request can no +// longer even name a foreign org. The invariant under test is therefore the +// tenant partition itself: integrations, connections, and tools written under +// one org's executor are invisible to another org's executor. import { describe, expect, it } from "@effect/vitest"; -import { Effect, Result, Schema } from "effect"; +import { Effect, Schema } from "effect"; import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; -import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; import { asOrg } from "../testing/api-harness"; @@ -19,106 +25,33 @@ const TenantIsolationApi = HttpApi.make("tenantIsolationTest") .add(PingGroup) .annotateMerge(OpenApi.annotations({ title: "Tenant Test API", version: "1.0.0" })); -const makeTenantOpenApiSourcePayload = ( - namespace: string, - options: Omit[1], "namespace"> = {}, +const makeTenantOpenApiSpecPayload = ( + slug: string, + options: Omit[1], "slug"> = {}, ) => makeOpenApiHttpApiTestAddSpecPayload(TenantIsolationApi, { - namespace, + slug, + baseUrl: "http://example.com", ...options, }); -describe("tenant isolation (HTTP)", () => { - it.effect("write requests cannot target another org scope", () => - Effect.gen(function* () { - const orgA = `org_${crypto.randomUUID()}`; - const orgB = `org_${crypto.randomUUID()}`; - - const result = yield* asOrg(orgB, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { - id: SecretId.make("planted"), - name: "planted-by-org-b", - value: "should-be-rejected", - }, - }), - ).pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - - const orgASecrets = yield* asOrg(orgA, (client) => - client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }), - ); - expect(orgASecrets.map((s) => s.id)).not.toContain("planted"); - }), - ); - - it.effect("read requests with another org scope still use the caller stack", () => - Effect.gen(function* () { - const orgA = `org_${crypto.randomUUID()}`; - const orgB = `org_${crypto.randomUUID()}`; - const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`; - - yield* asOrg(orgA, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { id: SecretId.make(idA), name: "org-a only", value: "v" }, - }), - ); - - const result = yield* asOrg(orgB, (client) => - client.secrets.list({ params: { scopeId: ScopeId.make(orgA) } }), - ); - expect(result.map((s) => s.id)).not.toContain(idA); - }), - ); - - it.effect("delete requests cannot remove another org secret", () => - Effect.gen(function* () { - const orgA = `org_${crypto.randomUUID()}`; - const orgB = `org_${crypto.randomUUID()}`; - const idA = `sec_a_${crypto.randomUUID().slice(0, 8)}`; - - yield* asOrg(orgA, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { id: SecretId.make(idA), name: "org-a only", value: "v" }, - }), - ); - - yield* asOrg(orgB, (client) => - client.secrets.remove({ - params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) }, - }), - ).pipe(Effect.result); - - const status = yield* asOrg(orgA, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(orgA), secretId: SecretId.make(idA) }, - }), - ); - expect(status.status).toBe("resolved"); - }), - ); +const randomSlug = () => IntegrationSlug.make(`a_${crypto.randomUUID().replace(/-/g, "_")}`); +const NAME_MAIN = ConnectionName.make("main"); +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); - it.effect("sources.list is scoped to the caller org", () => +describe("tenant isolation (HTTP)", () => { + it.effect("integrations.list is scoped to the caller org", () => Effect.gen(function* () { const orgA = `org_${crypto.randomUUID()}`; const orgB = `org_${crypto.randomUUID()}`; - const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`; + const slugA = randomSlug(); yield* asOrg(orgA, (client) => - client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgA) }, - payload: makeTenantOpenApiSourcePayload(namespaceA), - }), + client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) }), ); - const orgBSources = yield* asOrg(orgB, (client) => - client.sources.list({ params: { scopeId: ScopeId.make(orgB) } }), - ); - expect(orgBSources.map((s) => s.id)).not.toContain(namespaceA); + const orgBIntegrations = yield* asOrg(orgB, (client) => client.integrations.list({})); + expect(orgBIntegrations.map((s) => s.slug)).not.toContain(slugA); }), ); @@ -126,251 +59,138 @@ describe("tenant isolation (HTTP)", () => { Effect.gen(function* () { const orgA = `org_${crypto.randomUUID()}`; const orgB = `org_${crypto.randomUUID()}`; - const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`; + const slugA = randomSlug(); yield* asOrg(orgA, (client) => - client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgA) }, - payload: makeTenantOpenApiSourcePayload(namespaceA), + Effect.gen(function* () { + yield* client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) }); + yield* client.connections.create({ + payload: { + owner: "org", + name: NAME_MAIN, + integration: slugA, + template: TEMPLATE_API_KEY, + value: "v", + }, + }); }), ); - const orgBTools = yield* asOrg(orgB, (client) => - client.tools.list({ params: { scopeId: ScopeId.make(orgB) } }), - ); - expect(orgBTools.map((t) => t.sourceId)).not.toContain(namespaceA); - for (const id of orgBTools.map((t) => t.id)) { - expect(id).not.toContain(namespaceA); + const orgBTools = yield* asOrg(orgB, (client) => client.tools.list({ query: {} })); + for (const address of orgBTools.map((t) => t.address)) { + expect(address).not.toContain(slugA); } }), ); - it.effect("openapi.getSource cannot reach another org's source by namespace", () => + it.effect("openapi.getIntegration cannot reach another org's integration", () => Effect.gen(function* () { const orgA = `org_${crypto.randomUUID()}`; const orgB = `org_${crypto.randomUUID()}`; - const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`; + const slugA = randomSlug(); yield* asOrg(orgA, (client) => - client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgA) }, - payload: makeTenantOpenApiSourcePayload(namespaceA), - }), + client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) }), ); - const source = yield* asOrg(orgB, (client) => - client.openapi.getSource({ - params: { scopeId: ScopeId.make(orgB), namespace: namespaceA }, - }), + const integration = yield* asOrg(orgB, (client) => + client.openapi.getIntegration({ params: { slug: slugA } }), ); - expect(source).toBeNull(); + expect(integration).toBeNull(); }), ); - it.effect("secrets.list is scoped to the caller org", () => + it.effect("connections.list is scoped to the caller org", () => Effect.gen(function* () { const orgA = `org_${crypto.randomUUID()}`; const orgB = `org_${crypto.randomUUID()}`; - const secretIdA = `sec_a_${crypto.randomUUID().slice(0, 8)}`; - - yield* asOrg(orgA, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { id: SecretId.make(secretIdA), name: "org-a only", value: "super-secret-a" }, - }), - ); - - const orgBSecrets = yield* asOrg(orgB, (client) => - client.secrets.list({ params: { scopeId: ScopeId.make(orgB) } }), - ); - expect(orgBSecrets.map((s) => s.id)).not.toContain(secretIdA); - }), - ); - - it.effect("secrets.status reports another org's secret as missing", () => - Effect.gen(function* () { - const orgA = `org_${crypto.randomUUID()}`; - const orgB = `org_${crypto.randomUUID()}`; - const secretIdA = `sec_a_${crypto.randomUUID().slice(0, 8)}`; - - yield* asOrg(orgA, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { id: SecretId.make(secretIdA), name: "org-a only", value: "super-secret-a" }, - }), - ); - - const status = yield* asOrg(orgB, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(orgB), secretId: SecretId.make(secretIdA) }, - }), - ); - - expect(status.status).toBe("missing"); - }), - ); - - it.effect("secret metadata is not visible across orgs", () => - Effect.gen(function* () { - const orgA = `org_${crypto.randomUUID()}`; - const orgB = `org_${crypto.randomUUID()}`; - const secretIdA = `sec_a_${crypto.randomUUID().slice(0, 8)}`; - - yield* asOrg(orgA, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { id: SecretId.make(secretIdA), name: "org-a only", value: "super-secret-a" }, - }), - ); - - const status = yield* asOrg(orgB, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(orgB), secretId: SecretId.make(secretIdA) }, - }), - ); - const list = yield* asOrg(orgB, (client) => - client.secrets.list({ params: { scopeId: ScopeId.make(orgB) } }), - ); - - expect(status.status).toBe("missing"); - expect(list.map((s) => s.id)).not.toContain(secretIdA); - }), - ); - - it.effect("secret usages are scoped to the caller stack", () => - Effect.gen(function* () { - const orgA = `org_${crypto.randomUUID()}`; - const orgB = `org_${crypto.randomUUID()}`; - const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`; - const secretIdA = SecretId.make(`sec_a_${crypto.randomUUID().slice(0, 8)}`); + const slugA = randomSlug(); + const name = ConnectionName.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`); yield* asOrg(orgA, (client) => Effect.gen(function* () { - yield* client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { id: secretIdA, name: "org-a token", value: "v" }, - }); - yield* client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { - ...makeTenantOpenApiSourcePayload(namespaceA), - headers: { - Authorization: { - kind: "secret", - prefix: "Bearer ", - }, - }, - }, - }); - yield* client.sources.setBinding({ - params: { scopeId: ScopeId.make(orgA) }, + yield* client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) }); + yield* client.connections.create({ payload: { - scope: ScopeId.make(orgA), - source: { id: namespaceA, scope: ScopeId.make(orgA) }, - slotKey: "header:authorization", - value: { kind: "secret", secretId: secretIdA }, + owner: "org", + name, + integration: slugA, + template: TEMPLATE_API_KEY, + value: "super-secret-a", }, }); }), ); - const usages = yield* asOrg(orgB, (client) => - client.secrets.usages({ - params: { scopeId: ScopeId.make(orgB), secretId: secretIdA }, - }), + const orgBConnections = yield* asOrg(orgB, (client) => + client.connections.list({ query: {} }), ); - - expect(usages).toEqual([]); + expect(orgBConnections.map((c) => c.name)).not.toContain(name); }), ); - it.effect("connection usages are scoped to the caller stack", () => + it.effect("connection metadata and value are not visible across orgs", () => Effect.gen(function* () { const orgA = `org_${crypto.randomUUID()}`; const orgB = `org_${crypto.randomUUID()}`; - const namespaceA = `a_${crypto.randomUUID().replace(/-/g, "_")}`; - const connectionIdA = ConnectionId.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`); + const slugA = randomSlug(); + const name = ConnectionName.make(`conn_a_${crypto.randomUUID().slice(0, 8)}`); yield* asOrg(orgA, (client) => Effect.gen(function* () { - yield* client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgA) }, - payload: makeTenantOpenApiSourcePayload(namespaceA), - }); - yield* client.sources.setBinding({ - params: { scopeId: ScopeId.make(orgA) }, + yield* client.openapi.addSpec({ payload: makeTenantOpenApiSpecPayload(slugA) }); + yield* client.connections.create({ payload: { - scope: ScopeId.make(orgA), - source: { id: namespaceA, scope: ScopeId.make(orgA) }, - slotKey: "auth:conn", - value: { kind: "connection", connectionId: connectionIdA }, + owner: "org", + name, + integration: slugA, + template: TEMPLATE_API_KEY, + value: "super-secret-a", }, }); }), - ).pipe(Effect.result); - - const usages = yield* asOrg(orgB, (client) => - client.connections.usages({ - params: { scopeId: ScopeId.make(orgB), connectionId: connectionIdA }, - }), ); - expect(usages).toEqual([]); + const list = yield* asOrg(orgB, (client) => client.connections.list({ query: {} })); + expect(list.map((c) => c.name)).not.toContain(name); + expect(JSON.stringify(list)).not.toContain("super-secret-a"); }), ); - it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () => + it.effect("same-slug integration in two orgs are independent rows", () => Effect.gen(function* () { const orgA = `org_${crypto.randomUUID()}`; const orgB = `org_${crypto.randomUUID()}`; - const namespace = `shared_${crypto.randomUUID().replace(/-/g, "_")}`; + const slug = IntegrationSlug.make(`shared_${crypto.randomUUID().replace(/-/g, "_")}`); yield* asOrg(orgA, (client) => client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgA) }, - payload: makeTenantOpenApiSourcePayload(namespace, { - name: "Org A API", - baseUrl: "https://org-a.example.com", - }), + payload: { ...makeTenantOpenApiSpecPayload(slug), description: "Org A API" }, }), ); yield* asOrg(orgB, (client) => client.openapi.addSpec({ - params: { scopeId: ScopeId.make(orgB) }, - payload: makeTenantOpenApiSourcePayload(namespace, { - name: "Org B API", - baseUrl: "https://org-b.example.com", - }), + payload: { ...makeTenantOpenApiSpecPayload(slug), description: "Org B API" }, }), ); + // Updating org A's row must not mutate org B's same-slug row. yield* asOrg(orgA, (client) => - client.sources.configure({ - params: { scopeId: ScopeId.make(orgA) }, - payload: { - source: { id: namespace, scope: ScopeId.make(orgA) }, - scope: ScopeId.make(orgA), - type: "openapi", - config: { - scope: orgA, - name: "Org A Updated API", - baseUrl: "https://org-a-updated.example.com", - }, - }, + client.integrations.update({ + params: { slug }, + payload: { description: "Org A Updated API" }, }), ); - const orgASource = yield* asOrg(orgA, (client) => - client.openapi.getSource({ params: { scopeId: ScopeId.make(orgA), namespace } }), + const orgAIntegration = yield* asOrg(orgA, (client) => + client.integrations.get({ params: { slug } }), ); - const orgBSource = yield* asOrg(orgB, (client) => - client.openapi.getSource({ params: { scopeId: ScopeId.make(orgB), namespace } }), + const orgBIntegration = yield* asOrg(orgB, (client) => + client.integrations.get({ params: { slug } }), ); - expect(orgASource?.name).toBe("Org A Updated API"); - expect(orgASource?.config.baseUrl).toBe("https://org-a-updated.example.com"); - expect(orgBSource?.name).toBe("Org B API"); - expect(orgBSource?.config.baseUrl).toBe("https://org-b.example.com"); + expect(orgAIntegration?.description).toBe("Org A Updated API"); + expect(orgBIntegration?.description).toBe("Org B API"); }), ); }); diff --git a/apps/cloud/src/app.ts b/apps/cloud/src/app.ts index 9ae2bd124..7e6d6810c 100644 --- a/apps/cloud/src/app.ts +++ b/apps/cloud/src/app.ts @@ -5,6 +5,7 @@ import { DbProvider, ExecutorApp } from "@executor-js/api/server"; import { cloudPlugins } from "./plugins"; import { CoreSharedServices } from "./auth/workos"; +import { E2E_STUB, E2EStubWorkOSLayer } from "./testing/e2e-stub"; import { makeCloudExtensionRoutes } from "./extensions/routes"; import { RequestScopedServicesLive } from "./api/layers"; import { CloudMeteringEngineDecorator } from "./engine/execution-stack-metered"; @@ -17,6 +18,7 @@ import { McpSessionDO } from "./mcp/session-durable-object"; import { ErrorCaptureLive } from "./observability"; import { AutumnService } from "./extensions/billing/service"; import { + CLOUD_MOUNT_PREFIX, CloudCodeExecutorProvider, CloudDbProvider, CloudHostConfig, @@ -54,8 +56,12 @@ import { WorkerTelemetryLive } from "./observability/telemetry"; // the account provider + MCP seam, AND by the per-request identity layer below). // Lives in `boot`, so `workosIdentityLayer`'s residual `WorkOSClient | // ApiKeyService` (the long-lived control plane it reads) resolves from there. -const apiKeyService = ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)); -const controlPlane = Layer.mergeAll(CoreSharedServices, apiKeyService); +// EXECUTOR_E2E_STUB swaps the WorkOS control plane for an in-memory stub whose +// `authenticateSealedSession` resolves to user_1 — so the whole app (session + +// account + SSR) is logged in with no real WorkOS. Off in production. +const workOSBase = E2E_STUB ? E2EStubWorkOSLayer : CoreSharedServices; +const apiKeyService = ApiKeyService.WorkOS.pipe(Layer.provide(workOSBase)); +const controlPlane = Layer.mergeAll(workOSBase, apiKeyService); // `CloudDbProvider` only reads the per-request `DbService` at runtime; we widen // its residual type to also carry the boot `AutumnService` the metering @@ -100,7 +106,7 @@ const { appLayer, toWebHandler, mcpExport } = ExecutorApp.make({ routes: makeCloudExtensionRoutes(RequestScopedServicesLive), }, config: { - mountPrefix: "/api", + mountPrefix: CLOUD_MOUNT_PREFIX, // Cloud renders the shared identity errors as its exact `{ error, code }` // JSON at 401/403/503 (byte-identical to the old `HttpResponseError` bodies). failure: cloudIdentityFailureStrategy, diff --git a/apps/cloud/src/auth/auth-tool-failures.node.test.ts b/apps/cloud/src/auth/auth-tool-failures.node.test.ts index 87f10caa4..a905f89f3 100644 --- a/apps/cloud/src/auth/auth-tool-failures.node.test.ts +++ b/apps/cloud/src/auth/auth-tool-failures.node.test.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// Cloud app auth failure propagation +// Cloud app auth failure propagation (v2) // --------------------------------------------------------------------------- // // Exercises the cloud HTTP API boundary: @@ -7,16 +7,24 @@ // test -> HttpApiClient -> ProtectedCloudApi -> execution engine // -> sandbox code -> OpenAPI tool invocation // -// The assertion is intentionally on the final execution payload, not the -// plugin facade, so reviewers can see that model-visible tool results carry -// auth guidance instead of an opaque internal tool error. +// v2: a connection IS the credential. `addSpec` registers the integration with +// an apiKey auth template; a connection is then created whose value cannot +// resolve (a `from` reference to a WorkOS Vault item that was never stored). +// Invoking one of that connection's tools surfaces `connection_value_missing` +// to the model instead of an opaque internal tool error. // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { HttpApi, HttpApiClient, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { ScopeId } from "@executor-js/sdk"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, +} from "@executor-js/sdk"; import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; import { ProtectedCloudApi, asOrg } from "../testing/api-harness"; @@ -27,6 +35,11 @@ const PingGroup = HttpApiGroup.make("default", { topLevel: true }).add( const MissingAuthSourceApi = HttpApi.make("cloudAuthFailureSource").add(PingGroup); +const API_KEY_TEMPLATE = "apiKey"; +// The cloud default credential provider is the WorkOS Vault; a `from` reference +// to an item id that was never stored resolves to `null`. +const VAULT_PROVIDER = ProviderKey.make("workos-vault"); + type CloudApiShape = HttpApiClient.ForApi; type EffectSuccess = T extends Effect.Effect ? A : never; type ExecuteResult = EffectSuccess>; @@ -42,13 +55,9 @@ const expectModelVisibleAuthFailure = (execution: ExecuteResult) => { result: { ok: false, error: { - code: "credential_binding_missing", + code: "connection_value_missing", details: { category: "authentication", - recovery: { - createSecretTool: "executor.coreTools.secrets.create", - secretsUrl: "https://executor.sh/secrets", - }, }, }, }, @@ -56,32 +65,54 @@ const expectModelVisibleAuthFailure = (execution: ExecuteResult) => { }; describe("cloud auth tool failures", () => { - it.effect("cloud propagates missing credential binding as model-visible auth failure", () => + it.effect("cloud propagates a missing credential value as a model-visible auth failure", () => Effect.gen(function* () { const org = `org_${crypto.randomUUID()}`; - const namespace = `auth_${crypto.randomUUID().replace(/-/g, "_")}`; - const scopeId = ScopeId.make(org); + const integration = IntegrationSlug.make(`auth_${crypto.randomUUID().replace(/-/g, "_")}`); + const connection = ConnectionName.make("main"); yield* asOrg(org, (client) => client.openapi.addSpec({ - params: { scopeId }, payload: { ...makeOpenApiHttpApiTestAddSpecPayload(MissingAuthSourceApi, { - namespace, - headers: { - Authorization: { kind: "secret", prefix: "Bearer " }, - }, + slug: integration, + authenticationTemplate: [ + { + slug: AuthTemplateSlug.make(API_KEY_TEMPLATE), + type: "apiKey", + headers: { + Authorization: ["Bearer ", { type: "variable", name: "token" }], + }, + }, + ], }), baseUrl: "https://api.example.test", }, }), ); + // Create an org connection whose value cannot resolve: a `from` reference + // to a vault item that was never stored resolves to null. + yield* asOrg(org, (client) => + client.connections.create({ + payload: { + owner: "org", + name: connection, + integration, + template: AuthTemplateSlug.make(API_KEY_TEMPLATE), + from: { + provider: VAULT_PROVIDER, + id: ProviderItemId.make(`${integration}-missing`), + }, + }, + }), + ); + const execution = yield* asOrg(org, (client) => client.executions.execute({ payload: { code: [ - `const result = await tools.${namespace}.default.ping({});`, + `const result = await tools.${integration}.org.${connection}.default.ping({});`, "return result;", ].join("\n"), }, diff --git a/apps/cloud/src/auth/workos.test-layer.ts b/apps/cloud/src/auth/workos.test-layer.ts index 6b111367d..1bf1229ff 100644 --- a/apps/cloud/src/auth/workos.test-layer.ts +++ b/apps/cloud/src/auth/workos.test-layer.ts @@ -5,6 +5,15 @@ import { WorkOSClient, type WorkOSCollectedList } from "./workos"; export type WorkOSTestState = { readonly memberships: readonly OrganizationMembership[]; + // When set, a successful create adds a unique org id + an active membership, + // so a running server's free-org limit trips live across requests. Off by + // default — existing tests keep the fixed `org_created` id and static list. + readonly growMembershipsOnCreate?: boolean; + // When set, the user is resolved from the `wos-session` cookie value and + // memberships are keyed per user — so each test/browser picks its own user id + // and is isolated on a shared instance (no reset needed), matching the + // in-process per-org harness. Implies grow-on-create. + readonly multiUser?: boolean; readonly createdOrganizations: Array<{ readonly id: string; readonly name: string }>; readonly createdMemberships: Array<{ readonly organizationId: string; @@ -67,6 +76,15 @@ export const makeWorkOSTestMembership = ( customAttributes: {}, }) satisfies OrganizationMembership; +const decode = (value: string): string => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: tolerate non-URL-encoded cookie values from direct API callers + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + const collected = (data: readonly A[]): WorkOSCollectedList => ({ object: "list", data: [...data], @@ -78,32 +96,98 @@ const collected = (data: readonly A[]): WorkOSCollectedList => ({ const makeWorkOSTestService = (state: WorkOSTestState): WorkOSClient["Service"] => { const nextOrgId = "org_created"; + let orgCounter = 0; + // Per-user membership buckets (multi-user mode). Closure-scoped, so they live + // for the layer's lifetime (= the running server) and isolate users from each + // other without a reset. + const byUser = new Map(); + const memsFor = (userId: string) => { + let m = byUser.get(userId); + if (!m) byUser.set(userId, (m = [])); + return m; + }; + const grows = state.multiUser || state.growMembershipsOnCreate; const service: Partial = { - listUserMemberships: () => Effect.succeed(collected(state.memberships)), + listUserMemberships: (userId: string) => + Effect.succeed(collected(state.multiUser ? memsFor(userId) : state.memberships)), createOrganization: (name) => Effect.sync(() => { - const org = makeWorkOSTestOrganization(nextOrgId, name); + const id = grows ? `${nextOrgId}_${++orgCounter}` : nextOrgId; + const org = makeWorkOSTestOrganization(id, name); state.createdOrganizations.push({ id: org.id, name: org.name }); return org; }), createMembership: (organizationId, userId, roleSlug) => Effect.sync(() => { state.createdMemberships.push({ organizationId, userId, roleSlug }); - return makeWorkOSTestMembership(organizationId, "active"); + const membership = makeWorkOSTestMembership(organizationId, "active"); + if (state.multiUser) memsFor(userId).push(membership); + else if (state.growMembershipsOnCreate) { + (state.memberships as OrganizationMembership[]).push(membership); + } + return membership; + }), + getOrganization: (organizationId: string) => + Effect.succeed(makeWorkOSTestOrganization(organizationId)), + // The create-org page polls pending invitations; no invitation flows in + // the stub world, so the list is always empty. + listInvitationsByEmail: (_email: string) => Effect.succeed(collected([])), + getUserOrgMembership: (organizationId: string, _userId: string) => + Effect.succeed(makeWorkOSTestMembership(organizationId, "active")), + // Multi-user refresh carries the user id forward + the switched-to org, so the + // create-org handler's `verified.organizationId === org.id` check passes and + // the user stays consistent across the refresh. The incoming sealed session + // may be URL-encoded (set-cookie round-trip through a real browser) and may + // already carry an `|org:` suffix from a previous switch — normalize both. + refreshSession: (sealedSession, organizationId) => + Effect.succeed( + state.multiUser + ? `${userOf(sealedSession)}|org:${organizationId}` + : `session_${organizationId}`, + ), + authenticateSealedSession: (sealedSession) => Effect.sync(() => sessionOf(sealedSession)), + // The protected-API identity path (`resolveSessionPrincipal`) authenticates + // from the Request; mirror the real client's cookie-parse + delegate. + authenticateRequest: (request: Request) => + Effect.sync(() => { + const match = /(?:^|;\s*)wos-session=([^;]+)/.exec(request.headers.get("cookie") ?? ""); + return match ? sessionOf(decodeURIComponent(match[1]!)) : null; }), - refreshSession: (_sealedSession, organizationId) => Effect.succeed(`session_${organizationId}`), - authenticateSealedSession: (sealedSession) => - Effect.succeed({ - userId: "user_1", + }; + + function userOf(sealedSession: string): string { + return decode(sealedSession).split("|org:")[0] || "user_1"; + } + + function sessionOf(sealedSession: string) { + if (state.multiUser) { + // Cookie is `` (initial) or `|org:` (after refresh); + // a browser round-trip URL-encodes it. The LAST org segment is current. + const parts = decode(sealedSession).split("|org:"); + const userPart = parts[0]; + const orgPart = parts.length > 1 ? parts[parts.length - 1] : undefined; + return { + userId: userPart || "user_1", email: "test@example.com", firstName: "Test", lastName: "User", avatarUrl: null, - organizationId: sealedSession.replace("session_", ""), + organizationId: orgPart ?? "", sessionId: "session_id", refreshedSession: undefined, - }), - }; + }; + } + return { + userId: "user_1", + email: "test@example.com", + firstName: "Test", + lastName: "User", + avatarUrl: null, + organizationId: sealedSession.replace("session_", ""), + sessionId: "session_id", + refreshedSession: undefined, + }; + } return new Proxy(service as WorkOSClient["Service"], { get: (target, prop) => { diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index b0662caeb..6ccbab802 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -1,167 +1,212 @@ import { pgTable, + varchar, text, + json, boolean, timestamp, - varchar, uniqueIndex, - json, bigint, } from "drizzle-orm/pg-core"; import { createId } from "fumadb/cuid"; -export const source = pgTable( - "source", +export const integration = pgTable( + "integration", { + slug: varchar("slug", { length: 255 }).notNull(), plugin_id: text("plugin_id").notNull(), - kind: text("kind").notNull(), - name: text("name").notNull(), - url: text("url"), + description: text("description").notNull(), + config: json("config"), can_remove: boolean("can_remove").notNull().default(true), can_refresh: boolean("can_refresh").notNull().default(false), - can_edit: boolean("can_edit").notNull().default(false), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("source_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], ); -export const tool = pgTable( - "tool", +export const connection = pgTable( + "connection", { - source_id: text("source_id").notNull(), - plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), - description: text("description").notNull(), - input_schema: json("input_schema"), - output_schema: json("output_schema"), + integration: varchar("integration", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }).notNull(), + template: text("template").notNull(), + provider: text("provider").notNull(), + item_ids: json("item_ids").notNull(), + identity_label: text("identity_label"), + oauth_client: text("oauth_client"), + oauth_client_owner: text("oauth_client_owner"), + refresh_item_id: text("refresh_item_id"), + expires_at: bigint("expires_at", { mode: "bigint" }), + oauth_scope: text("oauth_scope"), + provider_state: json("provider_state"), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("tool_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [ + uniqueIndex("connection_uidx").on( + table.tenant, + table.owner, + table.subject, + table.integration, + table.name, + ), + ], ); -export const definition = pgTable( - "definition", +export const oauth_client = pgTable( + "oauth_client", { - source_id: text("source_id").notNull(), - plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), - schema: json("schema").notNull(), + slug: varchar("slug", { length: 255 }).notNull(), + authorization_url: text("authorization_url").notNull(), + token_url: text("token_url").notNull(), + grant: text("grant").notNull(), + client_id: text("client_id").notNull(), + client_secret_item_id: text("client_secret_item_id"), + resource: text("resource"), + origin_kind: text("origin_kind"), + origin_integration: text("origin_integration"), created_at: timestamp("created_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("definition_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [ + uniqueIndex("oauth_client_uidx").on(table.tenant, table.owner, table.subject, table.slug), + ], ); -export const secret = pgTable( - "secret", +export const oauth_session = pgTable( + "oauth_session", { + state: varchar("state", { length: 255 }).notNull(), + client_slug: text("client_slug").notNull(), + integration: text("integration").notNull(), name: text("name").notNull(), - provider: text("provider").notNull(), - owned_by_connection_id: text("owned_by_connection_id"), + template: text("template").notNull(), + redirect_url: text("redirect_url").notNull(), + pkce_verifier: text("pkce_verifier"), + identity_label: text("identity_label"), + payload: json("payload").notNull(), + expires_at: bigint("expires_at", { mode: "bigint" }).notNull(), created_at: timestamp("created_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("secret_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [uniqueIndex("oauth_session_uidx").on(table.tenant, table.state)], ); -export const connection = pgTable( - "connection", +export const tool = pgTable( + "tool", { - provider: text("provider").notNull(), - identity_label: text("identity_label"), - access_token_secret_id: text("access_token_secret_id").notNull(), - refresh_token_secret_id: text("refresh_token_secret_id"), - expires_at: bigint("expires_at", { mode: "bigint" }), - scope: text("scope"), - provider_state: json("provider_state"), - identity_override: json("identity_override"), + integration: varchar("integration", { length: 255 }).notNull(), + connection: varchar("connection", { length: 255 }).notNull(), + plugin_id: text("plugin_id").notNull(), + name: varchar("name", { length: 255 }).notNull(), + description: text("description").notNull(), + input_schema: json("input_schema"), + output_schema: json("output_schema"), + annotations: json("annotations"), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("connection_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [ + uniqueIndex("tool_uidx").on( + table.tenant, + table.owner, + table.subject, + table.integration, + table.connection, + table.name, + ), + ], ); -export const oauth2_session = pgTable( - "oauth2_session", +export const definition = pgTable( + "definition", { + integration: varchar("integration", { length: 255 }).notNull(), + connection: varchar("connection", { length: 255 }).notNull(), plugin_id: text("plugin_id").notNull(), - strategy: text("strategy").notNull(), - connection_id: text("connection_id").notNull(), - token_scope: text("token_scope").notNull(), - redirect_url: text("redirect_url").notNull(), - payload: json("payload").notNull(), - expires_at: bigint("expires_at", { mode: "bigint" }).notNull(), + name: text("name").notNull(), + schema: json("schema").notNull(), created_at: timestamp("created_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("oauth2_session_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [ + uniqueIndex("definition_uidx").on( + table.tenant, + table.owner, + table.subject, + table.integration, + table.connection, + table.name, + ), + ], ); -export const credential_binding = pgTable( - "credential_binding", +export const tool_policy = pgTable( + "tool_policy", { - plugin_id: text("plugin_id").notNull(), - source_id: text("source_id").notNull(), - source_scope_id: text("source_scope_id").notNull(), - slot_key: text("slot_key").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - secret_id: text("secret_id"), - secret_scope_id: text("secret_scope_id"), - connection_id: text("connection_id"), + id: varchar("id", { length: 255 }).notNull(), + pattern: text("pattern").notNull(), + action: text("action").notNull(), + position: text("position").notNull(), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("credential_binding_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [ + uniqueIndex("tool_policy_uidx").on(table.tenant, table.owner, table.subject, table.id), + ], ); export const plugin_storage = pgTable( "plugin_storage", { - plugin_id: text("plugin_id").notNull(), - collection: text("collection").notNull(), - key: text("key").notNull(), + plugin_id: varchar("plugin_id", { length: 255 }).notNull(), + collection: varchar("collection", { length: 255 }).notNull(), + key: varchar("key", { length: 255 }).notNull(), data: json("data").notNull(), created_at: timestamp("created_at").notNull(), updated_at: timestamp("updated_at").notNull(), @@ -169,35 +214,27 @@ export const plugin_storage = pgTable( .primaryKey() .notNull() .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), - }, - (table) => [uniqueIndex("plugin_storage_scope_id_id_uidx").on(table.scope_id, table.id)], -); - -export const tool_policy = pgTable( - "tool_policy", - { - pattern: text("pattern").notNull(), - action: text("action").notNull(), - position: text("position").notNull(), - created_at: timestamp("created_at").notNull(), - updated_at: timestamp("updated_at").notNull(), - row_id: varchar("row_id", { length: 255 }) - .primaryKey() - .notNull() - .$defaultFn(() => createId()), - id: varchar("id", { length: 255 }).notNull(), - scope_id: varchar("scope_id", { length: 255 }).notNull(), + tenant: varchar("tenant", { length: 255 }).notNull(), + owner: varchar("owner", { length: 255 }).notNull(), + subject: varchar("subject", { length: 255 }).notNull(), }, - (table) => [uniqueIndex("tool_policy_scope_id_id_uidx").on(table.scope_id, table.id)], + (table) => [ + uniqueIndex("plugin_storage_uidx").on( + table.tenant, + table.owner, + table.subject, + table.plugin_id, + table.collection, + table.key, + ), + ], ); export const blob = pgTable( "blob", { - namespace: text("namespace").notNull(), - key: text("key").notNull(), + namespace: varchar("namespace", { length: 255 }).notNull(), + key: varchar("key", { length: 255 }).notNull(), value: text("value").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() diff --git a/apps/cloud/src/db/fumadb-cutover-migration.node.test.ts b/apps/cloud/src/db/fumadb-cutover-migration.node.test.ts deleted file mode 100644 index 440934fc6..000000000 --- a/apps/cloud/src/db/fumadb-cutover-migration.node.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { readFileSync } from "node:fs"; -import { PGlite } from "@electric-sql/pglite"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; - -const scopedTables = [ - "connection", - "credential_binding", - "definition", - "graphql_operation", - "graphql_source", - "graphql_source_header", - "graphql_source_query_param", - "mcp_binding", - "mcp_source", - "mcp_source_header", - "mcp_source_query_param", - "oauth2_session", - "openapi_operation", - "openapi_source", - "openapi_source_header", - "openapi_source_query_param", - "openapi_source_spec_fetch_header", - "openapi_source_spec_fetch_query_param", - "secret", - "source", - "tool", - "tool_policy", - "workos_vault_metadata", -] as const; - -const migrationPath = new URL("../../drizzle/0016_fumadb_cutover.sql", import.meta.url); - -const statements = readFileSync(migrationPath, "utf8") - .split("--> statement-breakpoint") - .map((statement) => statement.trim()) - .filter((statement) => statement.length > 0); - -const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; - -const createLegacySchema = async (db: PGlite) => { - await db.exec(` - CREATE TABLE "blob" ( - "namespace" text NOT NULL, - "key" text NOT NULL, - "value" text NOT NULL, - CONSTRAINT "blob_namespace_key_pk" PRIMARY KEY ("namespace", "key") - ); - INSERT INTO "blob" ("namespace", "key", "value") VALUES ('scope/plugin', 'spec', '{}'); - `); - - for (const tableName of scopedTables) { - await db.exec(` - CREATE TABLE ${quoteIdent(tableName)} ( - "scope_id" text NOT NULL, - "id" text NOT NULL, - CONSTRAINT ${quoteIdent(`${tableName}_scope_id_id_pk`)} PRIMARY KEY ("scope_id", "id") - ); - INSERT INTO ${quoteIdent(tableName)} ("scope_id", "id") VALUES ('scope-a', 'row-a'); - `); - } -}; - -const applyCutoverMigration = async (db: PGlite) => { - for (const statement of statements) { - await db.exec(statement); - } -}; - -describe("FumaDB cutover migration", () => { - it.effect( - "converts legacy primary keys to row_id primary keys while preserving scoped uniqueness", - () => - Effect.acquireUseRelease( - Effect.promise(() => PGlite.create("memory://")), - (db) => - Effect.promise(async () => { - await createLegacySchema(db); - await applyCutoverMigration(db); - - const blobRows = await db.query<{ - id: string; - row_id: string; - }>(`SELECT "id", "row_id" FROM "blob"`); - expect(blobRows.rows).toEqual([ - { - id: '["scope/plugin","spec"]', - row_id: expect.stringMatching(/^legacy_/), - }, - ]); - - const blobConstraints = await db.query<{ conname: string }>( - `SELECT conname FROM pg_constraint WHERE conrelid = 'public.blob'::regclass ORDER BY conname`, - ); - expect(blobConstraints.rows.map((row) => row.conname)).toContain("blob_pkey"); - expect(blobConstraints.rows.map((row) => row.conname)).not.toContain( - "blob_namespace_key_pk", - ); - - for (const tableName of scopedTables) { - const rows = await db.query<{ row_id: string }>( - `SELECT "row_id" FROM ${quoteIdent(tableName)}`, - ); - expect(rows.rows).toEqual([{ row_id: expect.stringMatching(/^legacy_/) }]); - - const constraints = await db.query<{ conname: string }>( - `SELECT conname FROM pg_constraint WHERE conrelid = ${`'public.${tableName}'`}::regclass ORDER BY conname`, - ); - expect(constraints.rows.map((row) => row.conname)).toContain(`${tableName}_pkey`); - expect(constraints.rows.map((row) => row.conname)).not.toContain( - `${tableName}_scope_id_id_pk`, - ); - - const indexes = await db.query<{ indexname: string }>( - `SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = '${tableName}' ORDER BY indexname`, - ); - expect(indexes.rows.map((row) => row.indexname)).toContain( - `${tableName}_scope_id_id_uidx`, - ); - } - }), - (db) => Effect.promise(() => db.close()), - ), - 15_000, - ); -}); diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index beb5ead85..e9e977e03 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -66,6 +66,15 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( }), }); +/** + * The path prefix the cloud mounts its typed API under. SINGLE SOURCE OF TRUTH: + * `app.ts` passes this as `ExecutorApp.make({ config: { mountPrefix } })`, and + * `CloudHostConfig.oauthCallbackPath` derives the OAuth callback from it so the + * redirect URI the host sends to providers (`${webBaseUrl}${CLOUD_MOUNT_PREFIX}/oauth/callback`) + * always matches the route that actually serves the callback. + */ +export const CLOUD_MOUNT_PREFIX = "/api" as const; + export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, () => ({ // SSRF / private-network egress guard. Config-driven, NOT a test flag: // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); @@ -73,6 +82,13 @@ export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, ( // with `"true"` so fixtures can reach localhost. See `hosted-http-client.ts`. allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", + // The cloud serves the API (incl. the global `/oauth/callback`) under + // `${CLOUD_MOUNT_PREFIX}`, so the OAuth redirect URI MUST carry that prefix or + // it 404s on return and won't match the provider's registered redirect URI. + oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`, + // WorkOS Vault is cloud's credential storage implementation detail, not a + // user-selectable provider surface. + exposeCredentialProviders: false, })); export const CloudCodeExecutorProvider: Layer.Layer = Layer.sync( diff --git a/apps/cloud/src/extensions-reachability.test.ts b/apps/cloud/src/extensions-reachability.test.ts index 43ab6d47f..8d445f438 100644 --- a/apps/cloud/src/extensions-reachability.test.ts +++ b/apps/cloud/src/extensions-reachability.test.ts @@ -55,12 +55,12 @@ describe("cloud composed-handler reachability", () => { expect(res.headers.get("content-type")).toContain("application/json"); const spec = (await res.json()) as { paths?: Record }; expect(spec.paths).toBeDefined(); - // The spec is prefixed with /api, so a real route like scope is present. - expect(Object.keys(spec.paths ?? {}).some((p) => p.includes("/scope"))).toBe(true); + // The spec is prefixed with /api, so a real v2 route like integrations is present. + expect(Object.keys(spec.paths ?? {}).some((p) => p.includes("/integrations"))).toBe(true); }); - it("reaches the protected API auth gate at /api/scope (error JSON, NOT SPA HTML)", async () => { - const res = await call("GET", "/api/scope"); + it("reaches the protected API auth gate at /api/integrations (error JSON, NOT SPA HTML)", async () => { + const res = await call("GET", "/api/integrations"); expect([401, 403]).toContain(res.status); expect(res.headers.get("content-type")).toContain("application/json"); expect(await res.json()).toHaveProperty("code"); diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index b60f66c9c..921e92423 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -34,6 +34,7 @@ import { } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; +import { E2E_STUB, E2EStubAutumnLayer } from "../testing/e2e-stub"; import { OrgApi, OrgHttpApi } from "../org/api"; import { OrgHandlers } from "../org/handlers"; import { AutumnService } from "../extensions/billing/service"; @@ -74,8 +75,11 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer Effect.gen(function* () { const { baseUrl, seedOrg } = yield* Worker; @@ -780,17 +780,15 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) return { action: "accept" as const, content: {} }; }); - // User code inside `execute` (1) registers the upstream as an OpenAPI - // source and (2) invokes its POST operation. `annotationsForOperation` - // marks the POST as `requiresApproval: true`, which fires - // `enforceApproval` in the executor; that goes through the MCP - // elicitation handler and lands on `client.setRequestHandler` above. - // Tool id is `..` — Effect's - // `HttpApiGroup` name ("approve") becomes part of the sandbox path, - // so the invocation reads `tools.approveapi.approve.approveThing`. + // User code inside `execute` registers the upstream as an OpenAPI + // integration. The v2 static control tool is annotated + // `requiresApproval: true`, which fires `enforceApproval` in the + // executor; that goes through the MCP elicitation handler and lands on + // `client.setRequestHandler` above. Per-connection POST approval is + // covered in the plugin/SDK tests where the test memory provider exists. const code = [ - `await tools.executor.openapi.addSource({ name: "Approve API", baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, namespace: "approveapi" });`, - `return await tools.approveapi.approve.approveThing({});`, + `await tools.executor.openapi.addSpec({ baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, slug: "approveapi" });`, + `return await tools.executor.coreTools.integrations.list();`, ].join("\n"); const result = yield* Effect.promise(() => client.callTool({ name: "execute", arguments: { code } }), @@ -801,7 +799,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) ((result.content ?? []) as Array<{ type: string; text?: string }>).find( (c) => c.type === "text", )?.text ?? ""; - expect(text).toContain("approved"); + expect(text).toContain("approveapi"); yield* Effect.promise(() => client.close()); }), diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index c49955795..f33ca1776 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -2,8 +2,9 @@ // // The `McpSessionDO` in mcp-session.ts wires several things that previously // had zero integration coverage: -// - `createScopedExecutor` against a real FumaDB/Drizzle handle (the 2026-04-16 -// prod outage was a schema spread bug here; see db/db.schema.test.ts) +// - a per-request executor bound to `{ tenant, subject }` against a real +// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug +// here; see db/db.schema.test.ts) // - `createExecutionEngine` with an in-process code executor // - `createExecutorMcpServer` for the MCP request surface // - Real `@modelcontextprotocol/sdk` Client → server round-trips @@ -28,8 +29,8 @@ import { collectTables } from "@executor-js/api/server"; import { ElicitationResponse, FormElicitation, - Scope, - ScopeId, + Subject, + Tenant, createExecutor, definePlugin, } from "@executor-js/sdk"; @@ -97,7 +98,11 @@ type BuildOptions = { readonly elicitationMode?: "model" | "native"; }; -const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildOptions = {}) => +const buildScopedExecutor = ( + organizationId: string, + _organizationName: string, + options: BuildOptions = {}, +) => Effect.gen(function* () { const { db } = yield* DbService; const basePlugins = executorConfig.plugins({ @@ -112,13 +117,9 @@ const buildScopedExecutor = (scopeId: string, scopeName: string, options: BuildO namespace: "executor_cloud", provider: "postgresql", }); - const scope = Scope.make({ - id: ScopeId.make(scopeId), - name: scopeName, - createdAt: new Date(), - }); return yield* createExecutor({ - scopes: [scope], + tenant: Tenant.make(organizationId), + subject: Subject.make(`user_${organizationId}`), db: fuma.db, plugins, httpClientLayer: FetchHttpClient.layer, @@ -199,13 +200,13 @@ describe("cloud MCP session end-to-end", () => { // Isolates the drizzle adapter path so a schema spread drift surfaces as // a raw "unknown model" error. The prod outage on 2026-04-16 would have - // thrown at `executor.sources.list()` when the MCP session's drizzle + // thrown at `executor.integrations.list()` when the MCP session's drizzle // instance lost the executor-schema tables. - it.effect("exercises the drizzle adapter directly via executor.sources.list", () => + it.effect("exercises the drizzle adapter directly via executor.integrations.list", () => Effect.gen(function* () { const executor = yield* buildScopedExecutor(nextOrgId(), "drizzle-probe"); - const sources = yield* executor.sources.list(); - expect(Array.isArray(sources)).toBe(true); + const integrations = yield* executor.integrations.list(); + expect(Array.isArray(integrations)).toBe(true); }).pipe(Effect.provide(DbService.Live), Effect.scoped), ); diff --git a/apps/cloud/src/mcp/mcp-oauth.node.test.ts b/apps/cloud/src/mcp/mcp-oauth.node.test.ts index 13662ebc2..c696457e6 100644 --- a/apps/cloud/src/mcp/mcp-oauth.node.test.ts +++ b/apps/cloud/src/mcp/mcp-oauth.node.test.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// Cloud API × MCP OAuth — real HTTP end-to-end +// Cloud API × OAuth — real HTTP end-to-end (v2) // --------------------------------------------------------------------------- // // Drives the ProtectedCloudApi through the node-pool harness against the shared @@ -7,219 +7,136 @@ // plugin is real: // // test → HttpApiClient → in-process webHandler → ProtectedCloudApi -// → Core OAuthHandlers → executor.oauth.start / complete -// → MCP SDK `auth()` -// → OAuthTestServer (DCR, /authorize → login, /token, AS metadata, -// protected resource metadata, MCP protected resource) +// → Core OAuthHandlers → executor.oauth.{probe,createClient,start,cancel} // -// Two scenarios: +// v2: OAuth is a credential mechanism, not an integration type. `probe` +// discovers an authorization server's metadata; `createClient` registers an +// owner-scoped OAuth app; `start` runs the flow to mint a Connection. // -// 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts -// the response carries the Connection id the exchange minted. -// -// 2. Two users, same source: both users complete the shared OAuth flow -// and end up with their own Connection (same id, different scope) -// via the SDK's innermost-wins shadowing. +// v2: `start` runs an `authorization_code` client's flow by persisting an +// `oauth_session` and returning a `redirect` result whose `authorizationUrl` +// points at the OAuth server's authorize endpoint (the popup visits it; the +// callback later calls `complete`). The wired surface (`probe`, `createClient`, +// `start`, `cancel`) is exercised for real. // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Effect, Result } from "effect"; -import { ScopeId } from "@executor-js/sdk"; -import { serveOAuthTestServer, type OAuthTestServerShape } from "@executor-js/sdk/testing"; - -import { asOrg, asUser, testUserOrgScopeId } from "../testing/api-harness"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const countRequestsTo = (oauth: OAuthTestServerShape, path: string): Effect.Effect => - oauth.requests.pipe(Effect.map((requests) => requests.filter((r) => r.path === path).length)); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => { - it.effect( - "start rejects a redirectUrl on a different origin before discovery", - () => - Effect.gen(function* () { - const org = `org_${crypto.randomUUID()}`; - const scopeId = ScopeId.make(org); - const start = Date.now(); - - const result = yield* asOrg(org, (client) => - client.oauth.start({ - params: { scopeId }, - payload: { - endpoint: "https://example.test/api", - redirectUrl: "https://other.example/cb", - connectionId: "conn-foreign-redirect", - tokenScope: String(scopeId), - pluginId: "mcp", - strategy: { kind: "dynamic-dcr" }, - }, - }), - ).pipe(Effect.result); +import { Effect } from "effect"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + OAuthState, +} from "@executor-js/sdk"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; - expect(Result.isFailure(result)).toBe(true); - expect(Date.now() - start).toBeLessThan(1000); - }), - { timeout: 30_000 }, - ); +import { asOrg } from "../testing/api-harness"; +describe("oauth end-to-end (node pool, real OAuth server)", () => { it.effect( - "start rejects non-http redirectUrl schemes", + "probe discovers the authorization server's metadata", () => - Effect.gen(function* () { - const org = `org_${crypto.randomUUID()}`; - const scopeId = ScopeId.make(org); + Effect.scoped( + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer(); + const org = `org_${crypto.randomUUID()}`; - for (const redirectUrl of [ - "javascript:alert(1)", - "data:text/html,", - ]) { - const start = Date.now(); - const result = yield* asOrg(org, (client) => - client.oauth.start({ - params: { scopeId }, - payload: { - endpoint: "https://example.test/api", - redirectUrl, - connectionId: `conn-${crypto.randomUUID().slice(0, 8)}`, - tokenScope: String(scopeId), - pluginId: "mcp", - strategy: { kind: "dynamic-dcr" }, - }, - }), - ).pipe(Effect.result); + const probed = yield* asOrg(org, (client) => + client.oauth.probe({ payload: { url: oauth.issuerUrl } }), + ); - expect(Result.isFailure(result)).toBe(true); - expect(Date.now() - start).toBeLessThan(1000); - } - }), - { timeout: 30_000 }, + expect(probed.authorizationUrl).toBe(oauth.authorizationEndpoint); + expect(probed.tokenUrl).toBe(oauth.tokenEndpoint); + }), + ), + 30_000, ); it.effect( - "startOAuth → authorize → completeOAuth writes tokens at the invoker scope", + "createClient registers an owner-scoped OAuth app", () => Effect.scoped( Effect.gen(function* () { const oauth = yield* serveOAuthTestServer(); - const organizationId = `org_${crypto.randomUUID()}`; - const userId = `user_${crypto.randomUUID()}`; - const userScope = ScopeId.make(testUserOrgScopeId(userId, organizationId)); - const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; - const connectionId = `mcp-oauth2-${namespace}`; - const redirectUrl = "http://test.local/api/mcp/oauth/callback"; + const org = `org_${crypto.randomUUID()}`; + const slug = OAuthClientSlug.make(`client_${crypto.randomUUID().slice(0, 8)}`); - const started = yield* asUser(userId, organizationId, (client) => - client.oauth.start({ - params: { scopeId: userScope }, + const created = yield* asOrg(org, (client) => + client.oauth.createClient({ payload: { - endpoint: oauth.mcpResourceUrl, - redirectUrl, - connectionId, - tokenScope: String(userScope), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", + owner: "org", + slug, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", }, }), ); - expect(started.sessionId).toMatch(/^oauth2_session_/); - expect(started.authorizationUrl).not.toBeNull(); - - const { code, state } = yield* oauth.completeAuthorizationCodeFlow({ - authorizationUrl: started.authorizationUrl!, - }); - expect(state).toBe(started.sessionId); - - const completed = yield* asUser(userId, organizationId, (client) => - client.oauth.complete({ - params: { scopeId: userScope }, - payload: { state, code }, - }), - ); - expect(completed.connectionId).toBe(connectionId); + expect(created.client).toBe(slug); }), ), 30_000, ); it.effect( - "second user on same source re-uses DCR client: registration endpoint is not re-hit", + "start returns a redirect to the authorization endpoint", () => Effect.scoped( Effect.gen(function* () { const oauth = yield* serveOAuthTestServer(); - const organizationId = `org_${crypto.randomUUID()}`; - const userA = `user_${crypto.randomUUID()}`; - const userB = `user_${crypto.randomUUID()}`; - const scopeA = ScopeId.make(testUserOrgScopeId(userA, organizationId)); - const scopeB = ScopeId.make(testUserOrgScopeId(userB, organizationId)); - const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`; - const connectionId = `mcp-oauth2-${namespace}`; - const endpoint = oauth.mcpResourceUrl; - const redirectUrl = "http://test.local/api/mcp/oauth/callback"; - - const regsBefore = yield* countRequestsTo(oauth, "/register"); + const org = `org_${crypto.randomUUID()}`; + const slug = OAuthClientSlug.make(`client_${crypto.randomUUID().slice(0, 8)}`); - // --- User A: full OAuth round-trip, fresh DCR. --- - const startedA = yield* asUser(userA, organizationId, (client) => - client.oauth.start({ - params: { scopeId: scopeA }, + yield* asOrg(org, (client) => + client.oauth.createClient({ payload: { - endpoint, - redirectUrl, - connectionId, - tokenScope: String(scopeA), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", + owner: "org", + slug, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", }, }), ); - const redirA = yield* oauth.completeAuthorizationCodeFlow({ - authorizationUrl: startedA.authorizationUrl!, - }); - const completedA = yield* asUser(userA, organizationId, (client) => - client.oauth.complete({ - params: { scopeId: scopeA }, - payload: { state: redirA.state, code: redirA.code }, - }), - ); - expect(completedA.connectionId).toBe(connectionId); - expect(yield* countRequestsTo(oauth, "/register")).toBe(regsBefore + 1); - // --- User B: gets the same logical connection id in a different scope. --- - const startedB = yield* asUser(userB, organizationId, (client) => + const result = yield* asOrg(org, (client) => client.oauth.start({ - params: { scopeId: scopeB }, payload: { - endpoint, - redirectUrl, - connectionId, - tokenScope: String(scopeB), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", + client: slug, + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("some-integration"), + template: AuthTemplateSlug.make("oauth"), }, }), ); - const redirB = yield* oauth.completeAuthorizationCodeFlow({ - authorizationUrl: startedB.authorizationUrl!, + + expect(result).toMatchObject({ + status: "redirect", + authorizationUrl: expect.stringContaining(oauth.authorizationEndpoint), + state: expect.stringMatching(/.+/), }); - const completedB = yield* asUser(userB, organizationId, (client) => - client.oauth.complete({ - params: { scopeId: scopeB }, - payload: { state: redirB.state, code: redirB.code }, - }), - ); - expect(completedB.connectionId).toBe(connectionId); - expect(yield* countRequestsTo(oauth, "/register")).toBe(regsBefore + 2); }), ), 30_000, ); + + it.effect("cancel is idempotent for an unknown session", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const cancelled = yield* asOrg(org, (client) => + client.oauth.cancel({ + payload: { state: OAuthState.make("oauth2_session_does_not_exist") }, + }), + ); + expect(cancelled.cancelled).toBe(true); + }), + ); }); diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 70d2f29ce..d26bcf58a 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -22,7 +22,6 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres, { type Sql } from "postgres"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; -import { buildExecuteDescription } from "@executor-js/execution"; import { McpSessionDOBase, type BuiltMcpServer, @@ -30,6 +29,7 @@ import { type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/durable-object"; +import { buildExecuteDescription } from "@executor-js/execution"; // The DO only needs the neutral boot-scoped service (WorkOSClient). It never // bills, so it does NOT depend on any billing service — `CloudExecutionStackLayer` @@ -69,6 +69,7 @@ export type { const LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS = 5; const LONG_LIVED_DB_MAX_LIFETIME_SECONDS = 120; +const TELEMETRY_FLUSH_TIMEOUT_MS = 1_000; type CloudSessionDbHandle = DbServiceShape & { readonly sql: Sql; @@ -194,13 +195,9 @@ export class McpSessionDO extends McpSessionDOBase { Effect.provide(CloudExecutionStackLayer), Effect.withSpan("McpSessionDO.makeExecutionStack"), ); - // Build the description here so the postgres query it runs - // (`executor.sources.list`) lands as a child of `McpSessionDO.createRuntime`. - // It also tags the span with this org's source/connector inventory (ids, - // kinds, plugin ids, connection counts) — see `buildExecuteDescription` — - // so a failing init names *what* it was resolving without re-listing. - // host-mcp would otherwise call `Effect.runPromise(engine.getDescription)` - // at its async MCP-SDK boundary and orphan the sub-span. + // Build the description here so `executor.connections.list()` stays under + // the DO startup span and the MCP SDK receives a concrete string instead + // of invoking `engine.getDescription` across its async boundary. const description = yield* buildExecuteDescription(executor); const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; const mcpServer = yield* createExecutorMcpServer({ @@ -244,12 +241,26 @@ export class McpSessionDO extends McpSessionDOBase { reportCause(cause); } - // Force-export the DO isolate's buffered spans before the RPC settles, so a - // dying init/handleRequest still ships its own spans (and the exception + - // stack recorded on them) — not just the worker-side `mcp.do.*` span. The - // base wraps each entrypoint's outermost effect in an `ensuring` that awaits - // this after the span has ended and the SimpleSpanProcessor fired its export. + // Best-effort export the DO isolate's buffered spans after the RPC settles, + // so a dying init/handleRequest can ship its own spans (and the exception + + // stack recorded on them) — not just the worker-side `mcp.do.*` span. Keep it + // off the response path and bounded: telemetry export must not hold a + // successful MCP response open. protected override flushTelemetry(): Promise { - return flushTracerProvider(); + this.ctx.waitUntil( + Effect.runPromise( + Effect.tryPromise({ + try: () => flushTracerProvider(), + catch: () => undefined, + }).pipe( + Effect.ignore, + Effect.timeoutOrElse({ + duration: `${TELEMETRY_FLUSH_TIMEOUT_MS} millis`, + orElse: () => Effect.void, + }), + ), + ), + ); + return Promise.resolve(); } } diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index b4ee0efeb..96a9354e5 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -15,14 +15,13 @@ import { Route as SecretsRouteImport } from './routes/secrets' import { Route as PoliciesRouteImport } from './routes/policies' import { Route as OrgRouteImport } from './routes/org' import { Route as CreateOrgRouteImport } from './routes/create-org' -import { Route as ConnectionsRouteImport } from './routes/connections' import { Route as BillingRouteImport } from './routes/billing' import { Route as ApiKeysRouteImport } from './routes/api-keys' import { Route as IndexRouteImport } from './routes/index' -import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' +import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace' import { Route as BillingPlansRouteImport } from './routes/billing_.plans' -import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' +import { Route as IntegrationsAddPluginKeyRouteImport } from './routes/integrations.add.$pluginKey' const ToolsRoute = ToolsRouteImport.update({ id: '/tools', @@ -54,11 +53,6 @@ const CreateOrgRoute = CreateOrgRouteImport.update({ path: '/create-org', getParentRoute: () => rootRouteImport, } as any) -const ConnectionsRoute = ConnectionsRouteImport.update({ - id: '/connections', - path: '/connections', - getParentRoute: () => rootRouteImport, -} as any) const BillingRoute = BillingRouteImport.update({ id: '/billing', path: '/billing', @@ -74,32 +68,32 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) -const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ - id: '/sources/$namespace', - path: '/sources/$namespace', - getParentRoute: () => rootRouteImport, -} as any) const ResumeExecutionIdRoute = ResumeExecutionIdRouteImport.update({ id: '/resume/$executionId', path: '/resume/$executionId', getParentRoute: () => rootRouteImport, } as any) +const IntegrationsNamespaceRoute = IntegrationsNamespaceRouteImport.update({ + id: '/integrations/$namespace', + path: '/integrations/$namespace', + getParentRoute: () => rootRouteImport, +} as any) const BillingPlansRoute = BillingPlansRouteImport.update({ id: '/billing_/plans', path: '/billing/plans', getParentRoute: () => rootRouteImport, } as any) -const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ - id: '/sources/add/$pluginKey', - path: '/sources/add/$pluginKey', - getParentRoute: () => rootRouteImport, -} as any) +const IntegrationsAddPluginKeyRoute = + IntegrationsAddPluginKeyRouteImport.update({ + id: '/integrations/add/$pluginKey', + path: '/integrations/add/$pluginKey', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/api-keys': typeof ApiKeysRoute '/billing': typeof BillingRoute - '/connections': typeof ConnectionsRoute '/create-org': typeof CreateOrgRoute '/org': typeof OrgRoute '/policies': typeof PoliciesRoute @@ -107,15 +101,14 @@ export interface FileRoutesByFullPath { '/setup-mcp': typeof SetupMcpRoute '/tools': typeof ToolsRoute '/billing/plans': typeof BillingPlansRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/resume/$executionId': typeof ResumeExecutionIdRoute - '/sources/$namespace': typeof SourcesNamespaceRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/api-keys': typeof ApiKeysRoute '/billing': typeof BillingRoute - '/connections': typeof ConnectionsRoute '/create-org': typeof CreateOrgRoute '/org': typeof OrgRoute '/policies': typeof PoliciesRoute @@ -123,16 +116,15 @@ export interface FileRoutesByTo { '/setup-mcp': typeof SetupMcpRoute '/tools': typeof ToolsRoute '/billing/plans': typeof BillingPlansRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/resume/$executionId': typeof ResumeExecutionIdRoute - '/sources/$namespace': typeof SourcesNamespaceRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/api-keys': typeof ApiKeysRoute '/billing': typeof BillingRoute - '/connections': typeof ConnectionsRoute '/create-org': typeof CreateOrgRoute '/org': typeof OrgRoute '/policies': typeof PoliciesRoute @@ -140,9 +132,9 @@ export interface FileRoutesById { '/setup-mcp': typeof SetupMcpRoute '/tools': typeof ToolsRoute '/billing_/plans': typeof BillingPlansRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/resume/$executionId': typeof ResumeExecutionIdRoute - '/sources/$namespace': typeof SourcesNamespaceRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -150,7 +142,6 @@ export interface FileRouteTypes { | '/' | '/api-keys' | '/billing' - | '/connections' | '/create-org' | '/org' | '/policies' @@ -158,15 +149,14 @@ export interface FileRouteTypes { | '/setup-mcp' | '/tools' | '/billing/plans' + | '/integrations/$namespace' | '/resume/$executionId' - | '/sources/$namespace' - | '/sources/add/$pluginKey' + | '/integrations/add/$pluginKey' fileRoutesByTo: FileRoutesByTo to: | '/' | '/api-keys' | '/billing' - | '/connections' | '/create-org' | '/org' | '/policies' @@ -174,15 +164,14 @@ export interface FileRouteTypes { | '/setup-mcp' | '/tools' | '/billing/plans' + | '/integrations/$namespace' | '/resume/$executionId' - | '/sources/$namespace' - | '/sources/add/$pluginKey' + | '/integrations/add/$pluginKey' id: | '__root__' | '/' | '/api-keys' | '/billing' - | '/connections' | '/create-org' | '/org' | '/policies' @@ -190,16 +179,15 @@ export interface FileRouteTypes { | '/setup-mcp' | '/tools' | '/billing_/plans' + | '/integrations/$namespace' | '/resume/$executionId' - | '/sources/$namespace' - | '/sources/add/$pluginKey' + | '/integrations/add/$pluginKey' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ApiKeysRoute: typeof ApiKeysRoute BillingRoute: typeof BillingRoute - ConnectionsRoute: typeof ConnectionsRoute CreateOrgRoute: typeof CreateOrgRoute OrgRoute: typeof OrgRoute PoliciesRoute: typeof PoliciesRoute @@ -207,9 +195,9 @@ export interface RootRouteChildren { SetupMcpRoute: typeof SetupMcpRoute ToolsRoute: typeof ToolsRoute BillingPlansRoute: typeof BillingPlansRoute + IntegrationsNamespaceRoute: typeof IntegrationsNamespaceRoute ResumeExecutionIdRoute: typeof ResumeExecutionIdRoute - SourcesNamespaceRoute: typeof SourcesNamespaceRoute - SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute + IntegrationsAddPluginKeyRoute: typeof IntegrationsAddPluginKeyRoute } declare module '@tanstack/react-router' { @@ -256,13 +244,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CreateOrgRouteImport parentRoute: typeof rootRouteImport } - '/connections': { - id: '/connections' - path: '/connections' - fullPath: '/connections' - preLoaderRoute: typeof ConnectionsRouteImport - parentRoute: typeof rootRouteImport - } '/billing': { id: '/billing' path: '/billing' @@ -284,13 +265,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/sources/$namespace': { - id: '/sources/$namespace' - path: '/sources/$namespace' - fullPath: '/sources/$namespace' - preLoaderRoute: typeof SourcesNamespaceRouteImport - parentRoute: typeof rootRouteImport - } '/resume/$executionId': { id: '/resume/$executionId' path: '/resume/$executionId' @@ -298,6 +272,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResumeExecutionIdRouteImport parentRoute: typeof rootRouteImport } + '/integrations/$namespace': { + id: '/integrations/$namespace' + path: '/integrations/$namespace' + fullPath: '/integrations/$namespace' + preLoaderRoute: typeof IntegrationsNamespaceRouteImport + parentRoute: typeof rootRouteImport + } '/billing_/plans': { id: '/billing_/plans' path: '/billing/plans' @@ -305,11 +286,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof BillingPlansRouteImport parentRoute: typeof rootRouteImport } - '/sources/add/$pluginKey': { - id: '/sources/add/$pluginKey' - path: '/sources/add/$pluginKey' - fullPath: '/sources/add/$pluginKey' - preLoaderRoute: typeof SourcesAddPluginKeyRouteImport + '/integrations/add/$pluginKey': { + id: '/integrations/add/$pluginKey' + path: '/integrations/add/$pluginKey' + fullPath: '/integrations/add/$pluginKey' + preLoaderRoute: typeof IntegrationsAddPluginKeyRouteImport parentRoute: typeof rootRouteImport } } @@ -319,7 +300,6 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ApiKeysRoute: ApiKeysRoute, BillingRoute: BillingRoute, - ConnectionsRoute: ConnectionsRoute, CreateOrgRoute: CreateOrgRoute, OrgRoute: OrgRoute, PoliciesRoute: PoliciesRoute, @@ -327,9 +307,9 @@ const rootRouteChildren: RootRouteChildren = { SetupMcpRoute: SetupMcpRoute, ToolsRoute: ToolsRoute, BillingPlansRoute: BillingPlansRoute, + IntegrationsNamespaceRoute: IntegrationsNamespaceRoute, ResumeExecutionIdRoute: ResumeExecutionIdRoute, - SourcesNamespaceRoute: SourcesNamespaceRoute, - SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, + IntegrationsAddPluginKeyRoute: IntegrationsAddPluginKeyRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index d6e95e87c..d66288bd8 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -92,7 +92,6 @@ export const Route = createRootRoute({ }, { rel: "stylesheet", href: appCss }, ], - scripts: import.meta.env.DEV ? [{ src: "https://ui.sh/ui-picker.js" }] : [], }), component: RootComponent, shellComponent: RootDocument, @@ -239,13 +238,15 @@ function AuthGate() { return ( } showDialog={false}> - } onHandledError={captureFrontendError}> - - - - - - + + }> + + + + + + + diff --git a/apps/cloud/src/routes/connections.tsx b/apps/cloud/src/routes/connections.tsx deleted file mode 100644 index ae9f0af5a..000000000 --- a/apps/cloud/src/routes/connections.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ConnectionsPage } from "@executor-js/react/pages/connections"; - -export const Route = createFileRoute("/connections")({ - component: () => , -}); diff --git a/apps/cloud/src/routes/index.tsx b/apps/cloud/src/routes/index.tsx index 01273b87a..2d57f82f0 100644 --- a/apps/cloud/src/routes/index.tsx +++ b/apps/cloud/src/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { SourcesPage } from "@executor-js/react/pages/sources"; +import { IntegrationsPage } from "@executor-js/react/pages/integrations"; export const Route = createFileRoute("/")({ - component: SourcesPage, + component: IntegrationsPage, }); diff --git a/apps/cloud/src/routes/integrations.$namespace.tsx b/apps/cloud/src/routes/integrations.$namespace.tsx new file mode 100644 index 000000000..49a458104 --- /dev/null +++ b/apps/cloud/src/routes/integrations.$namespace.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IntegrationDetailPage } from "@executor-js/react/pages/integration-detail"; + +export const Route = createFileRoute("/integrations/$namespace")({ + component: () => { + const { namespace } = Route.useParams(); + return ; + }, +}); diff --git a/apps/cloud/src/routes/sources.add.$pluginKey.tsx b/apps/cloud/src/routes/integrations.add.$pluginKey.tsx similarity index 63% rename from apps/cloud/src/routes/sources.add.$pluginKey.tsx rename to apps/cloud/src/routes/integrations.add.$pluginKey.tsx index 48d58b32d..cdf2b8a8a 100644 --- a/apps/cloud/src/routes/sources.add.$pluginKey.tsx +++ b/apps/cloud/src/routes/integrations.add.$pluginKey.tsx @@ -1,6 +1,6 @@ import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; -import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; +import { AddIntegrationPage } from "@executor-js/react/pages/integration-add"; const SearchParams = Schema.toStandardSchemaV1( Schema.Struct({ @@ -10,11 +10,13 @@ const SearchParams = Schema.toStandardSchemaV1( }), ); -export const Route = createFileRoute("/sources/add/$pluginKey")({ +export const Route = createFileRoute("/integrations/add/$pluginKey")({ validateSearch: SearchParams, component: () => { const { pluginKey } = Route.useParams(); const { url, preset, namespace } = Route.useSearch(); - return ; + return ( + + ); }, }); diff --git a/apps/cloud/src/routes/org.tsx b/apps/cloud/src/routes/org.tsx index 43edadffb..0f0c336bd 100644 --- a/apps/cloud/src/routes/org.tsx +++ b/apps/cloud/src/routes/org.tsx @@ -15,7 +15,6 @@ import { DropdownMenuTrigger, } from "@executor-js/react/components/dropdown-menu"; import { OrgPage as SharedOrgPage } from "@executor-js/react/pages/org"; -import { orgMembersAtom } from "@executor-js/react/api/account-atoms"; import { orgDomainsAtom, getDomainVerificationLink, deleteDomain } from "../web/org-atoms"; // --------------------------------------------------------------------------- @@ -44,38 +43,8 @@ type DomainData = { function OrgPage() { return (
-
- - -
{/* Shared members / roles / invite / org-name surface. */} - -
- ); -} - -// Autumn-backed member-seat banner. The hard cap is enforced server-side in -// the `/account/inviteMember` handler (AccountForbidden), so this is purely an -// affordance: surface the upgrade CTA once the org is at/over its seat limit. -function MemberLimitBanner() { - const membersResult = useAtomValue(orgMembersAtom); - const seats = AsyncResult.match(membersResult, { - onInitial: () => null, - onFailure: () => null, - onSuccess: ({ value }) => value.seats ?? null, - }); - const atLimit = seats ? !seats.unlimited && seats.used >= seats.granted : false; - if (!atLimit) return null; - return ( -
-

- You've reached your member limit. Upgrade to Team to invite more. -

- - - + } />
); } @@ -83,7 +52,9 @@ function MemberLimitBanner() { function DomainsSection() { const domainsResult = useAtomValue(orgDomainsAtom); const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" }); - const doGetVerificationLink = useAtomSet(getDomainVerificationLink, { mode: "promiseExit" }); + const doGetVerificationLink = useAtomSet(getDomainVerificationLink, { + mode: "promiseExit", + }); const { check, isLoading: customerLoading } = useCustomer(); const canUseDomains = customerLoading ? false @@ -100,7 +71,9 @@ function DomainsSection() { }; const handleAddDomain = async () => { - const exit = await doGetVerificationLink({ reactivityKeys: orgDomainWriteKeys }); + const exit = await doGetVerificationLink({ + reactivityKeys: orgDomainWriteKeys, + }); if (Exit.isSuccess(exit)) { window.open(exit.value.link, "_blank"); } else { diff --git a/apps/cloud/src/routes/secrets.tsx b/apps/cloud/src/routes/secrets.tsx index f67981f43..a4e32244c 100644 --- a/apps/cloud/src/routes/secrets.tsx +++ b/apps/cloud/src/routes/secrets.tsx @@ -1,28 +1,11 @@ -import { Schema } from "effect"; -import { createFileRoute } from "@tanstack/react-router"; -import { SecretsPage } from "@executor-js/react/pages/secrets"; - -const SearchParams = Schema.toStandardSchemaV1( - Schema.Struct({ - name: Schema.optional(Schema.String), - secretId: Schema.optional(Schema.String), - provider: Schema.optional(Schema.String), - scope: Schema.optional(Schema.String), - }), -); +import { createFileRoute, redirect } from "@tanstack/react-router"; +// Cloud keeps credential storage as product plumbing, not a user-facing section. +// Preserve the route for generated router compatibility and stale links, but +// redirect away from the provider internals page. export const Route = createFileRoute("/secrets")({ - validateSearch: SearchParams, - component: () => { - const { name, secretId, provider, scope } = Route.useSearch(); - const hasPrefill = name != null || secretId != null; - return ( - - ); + beforeLoad: () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router redirects are modeled as thrown values + throw redirect({ to: "/" }); }, }); diff --git a/apps/cloud/src/routes/sources.$namespace.tsx b/apps/cloud/src/routes/sources.$namespace.tsx deleted file mode 100644 index 2bcdcce73..000000000 --- a/apps/cloud/src/routes/sources.$namespace.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; - -export const Route = createFileRoute("/sources/$namespace")({ - component: () => { - const { namespace } = Route.useParams(); - return ; - }, -}); diff --git a/apps/cloud/src/secrets-isolation.e2e.node.test.ts b/apps/cloud/src/secrets-isolation.e2e.node.test.ts index fb0c54c8f..4aae6f417 100644 --- a/apps/cloud/src/secrets-isolation.e2e.node.test.ts +++ b/apps/cloud/src/secrets-isolation.e2e.node.test.ts @@ -1,244 +1,204 @@ -// End-to-end coverage for secret isolation *through the real HTTP API*. +// End-to-end coverage for connection (credential) isolation *through the real +// HTTP API* (v2). // -// Complements tenant-isolation.node.test.ts (which already covers plain -// cross-org isolation at the org scope) by exercising the two-scope stack -// the cloud app actually ships: `[userOrgScope, orgScope]`. The harness -// builds the same shape `apps/cloud/src/services/executor.ts#createScopedExecutor` -// builds in production, and every request goes through `HttpApiClient` → -// `fetch` → the real `ProtectedCloudApi` → the real Drizzle/FumaDB path. +// Complements tenant-isolation.node.test.ts (plain cross-org isolation) by +// exercising the owner model the cloud app actually ships: the per-request +// executor binds `{ tenant: organizationId, subject: accountId }`, and every +// connection is filed under `owner: "org"` (tenant-shared) or `owner: "user"` +// (this subject's own). Every request goes through `HttpApiClient` → `fetch` → +// the real `ProtectedCloudApi` → the real Drizzle/FumaDB path. // // Invariants the product is staking on: // -// 1. Users in different orgs can't see each other's secret metadata. -// 2. Users in the same org can't see each other's user-scoped secret -// metadata (per-user OAuth tokens etc. don't leak to co-workers). -// 3. Org-scoped secret metadata IS visible to every user in that org -// — an admin writing a shared API key serves the whole tenant. -// 4. The same user id in different orgs gets distinct per-user scopes — -// the userOrgScope id bakes in the org id on purpose. -// 5. secrets.set rejects a scope id outside the caller's executor stack. -// -// NOTE: "per-user override shadows org default" cross-scope co-existence -// is NOT covered here. `executor.secrets.set` currently deletes secret -// metadata rows across the full scope stack before re-inserting at the -// target scope (see executor.ts `secretsSet`), so an overrider writing -// at their user-org scope wipes the org-level default rather than -// shadowing it. If the product wants both rows to coexist, that's an -// SDK-level change — coverage for it belongs after the fix. +// 1. Users in different orgs can't see each other's org connections. +// 2. Users in the same org can't see each other's user-owned connections +// (per-user OAuth tokens etc. don't leak to co-workers). +// 3. Org-owned connections ARE visible to every user in that org — an admin +// writing a shared API key serves the whole tenant. +// 4. The same user id in different orgs is a different tenant binding — a +// user connection written in org A is invisible in org B. import { describe, expect, it } from "@effect/vitest"; -import { Effect, Result } from "effect"; +import { Effect } from "effect"; +import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"; +import { Schema } from "effect"; -import { ScopeId, SecretId } from "@executor-js/sdk"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + connectionIdentifier, +} from "@executor-js/sdk"; +import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; -import { asUser, testUserOrgScopeId } from "./testing/api-harness"; +import { asUser } from "./testing/api-harness"; const uniq = () => crypto.randomUUID().slice(0, 8); const nextOrgId = () => `org_iso_${uniq()}`; const nextUserId = () => `user_iso_${uniq()}`; -describe("cloud secret isolation (HTTP, user-org scope stack)", () => { - it.effect("users in different orgs cannot read each other's org-scoped secrets", () => +const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey"); +const canonicalConnectionName = (name: ConnectionName): ConnectionName => + connectionIdentifier(String(name)); + +const PingApi = HttpApi.make("isolationApiTest") + .add( + HttpApiGroup.make("default", { topLevel: true }).add( + HttpApiEndpoint.get("ping", "/ping", { success: Schema.Unknown }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "Isolation API Test", version: "1.0.0" })); + +// Registers a minimal openapi integration under `org` (acting as `userId`) so +// connections have an integration to bind to. Returns the slug. +const registerIntegration = (userId: string, org: string) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(`ns_${crypto.randomUUID().replace(/-/g, "_")}`); + yield* asUser(userId, org, (client) => + client.openapi.addSpec({ + payload: makeOpenApiHttpApiTestAddSpecPayload(PingApi, { + slug, + baseUrl: "http://example.com", + }), + }), + ); + return slug; + }); + +describe("cloud connection isolation (HTTP, owner model)", () => { + it.effect("users in different orgs cannot read each other's org connections", () => Effect.gen(function* () { const orgA = nextOrgId(); const orgB = nextOrgId(); const alice = nextUserId(); const charlie = nextUserId(); - const id = `sec_${uniq()}`; + const name = ConnectionName.make(`conn_${uniq()}`); + const storedName = canonicalConnectionName(name); + const integrationA = yield* registerIntegration(alice, orgA); yield* asUser(alice, orgA, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(orgA) }, + client.connections.create({ payload: { - id: SecretId.make(id), - name: "Shared", + owner: "org", + name, + integration: integrationA, + template: TEMPLATE_API_KEY, value: "alice-org-secret", }, }), ); - const charlieStatus = yield* asUser(charlie, orgB, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(orgB), secretId: SecretId.make(id) }, - }), - ); - expect(charlieStatus.status).toBe("missing"); - + // Charlie in a different org sees no connections under his (empty) catalog. const charlieList = yield* asUser(charlie, orgB, (client) => - client.secrets.list({ params: { scopeId: ScopeId.make(orgB) } }), + client.connections.list({ query: {} }), ); - expect(charlieList.map((s) => s.id)).not.toContain(id); + expect(charlieList.map((c) => c.name)).not.toContain(storedName); }), ); - it.effect("users in same org cannot read each other's user-scoped secrets", () => + it.effect("users in same org cannot read each other's user-owned connections", () => Effect.gen(function* () { const organizationId = nextOrgId(); const aliceId = nextUserId(); const bobId = nextUserId(); - const id = `sec_${uniq()}`; + const name = ConnectionName.make(`conn_${uniq()}`); + const storedName = canonicalConnectionName(name); - // Alice writes at her per-user scope — where OAuth tokens land. + const integration = yield* registerIntegration(aliceId, organizationId); + + // Alice writes her personal (`owner: "user"`) connection. yield* asUser(aliceId, organizationId, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(testUserOrgScopeId(aliceId, organizationId)) }, + client.connections.create({ payload: { - id: SecretId.make(id), - name: "Alice's token", + owner: "user", + name, + integration, + template: TEMPLATE_API_KEY, value: "alice-token-value", }, }), ); - // Bob is in the same org — his user-org scope differs. He should - // not see the token in a list. - const bobList = yield* asUser(bobId, organizationId, (client) => - client.secrets.list({ - params: { scopeId: ScopeId.make(testUserOrgScopeId(bobId, organizationId)) }, - }), + // Bob is in the same org — his subject differs. He must not see Alice's + // user connection in a user-owner list. + const bobUserList = yield* asUser(bobId, organizationId, (client) => + client.connections.list({ query: { integration, owner: "user" } }), ); - expect(bobList.map((s) => s.id)).not.toContain(id); + expect(bobUserList.map((c) => c.name)).not.toContain(storedName); - const bobStatus = yield* asUser(bobId, organizationId, (client) => - client.secrets.status({ - params: { - scopeId: ScopeId.make(testUserOrgScopeId(bobId, organizationId)), - secretId: SecretId.make(id), - }, - }), - ); - expect(bobStatus.status).toBe("missing"); - - // And Alice still sees her own token metadata. - const aliceStatus = yield* asUser(aliceId, organizationId, (client) => - client.secrets.status({ - params: { - scopeId: ScopeId.make(testUserOrgScopeId(aliceId, organizationId)), - secretId: SecretId.make(id), - }, - }), + // And Alice still sees her own connection. + const aliceUserList = yield* asUser(aliceId, organizationId, (client) => + client.connections.list({ query: { integration, owner: "user" } }), ); - expect(aliceStatus.status).toBe("resolved"); + expect(aliceUserList.map((c) => c.name)).toContain(storedName); }), ); - it.effect("org-scoped secrets are visible to every user in that org", () => + it.effect("org-owned connections are visible to every user in that org", () => Effect.gen(function* () { const organizationId = nextOrgId(); const adminId = nextUserId(); const memberId = nextUserId(); - const id = `sec_${uniq()}`; + const name = ConnectionName.make(`conn_${uniq()}`); + const storedName = canonicalConnectionName(name); + const integration = yield* registerIntegration(adminId, organizationId); yield* asUser(adminId, organizationId, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(organizationId) }, + client.connections.create({ payload: { - id: SecretId.make(id), - name: "Org API Key", + owner: "org", + name, + integration, + template: TEMPLATE_API_KEY, value: "shared-org-key", }, }), ); - const adminStatus = yield* asUser(adminId, organizationId, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(organizationId), secretId: SecretId.make(id) }, - }), + const adminList = yield* asUser(adminId, organizationId, (client) => + client.connections.list({ query: { integration, owner: "org" } }), ); - const memberStatus = yield* asUser(memberId, organizationId, (client) => - client.secrets.status({ - params: { scopeId: ScopeId.make(organizationId), secretId: SecretId.make(id) }, - }), + const memberList = yield* asUser(memberId, organizationId, (client) => + client.connections.list({ query: { integration, owner: "org" } }), ); - expect(adminStatus.status).toBe("resolved"); - expect(memberStatus.status).toBe("resolved"); + expect(adminList.map((c) => c.name)).toContain(storedName); + expect(memberList.map((c) => c.name)).toContain(storedName); }), ); - it.effect("same userId in different orgs gets distinct per-user scopes", () => + it.effect("same userId in different orgs is a distinct tenant binding", () => Effect.gen(function* () { const userId = nextUserId(); const orgA = nextOrgId(); const orgB = nextOrgId(); - const id = `sec_${uniq()}`; + const name = ConnectionName.make(`conn_${uniq()}`); + const storedName = canonicalConnectionName(name); + const integrationA = yield* registerIntegration(userId, orgA); yield* asUser(userId, orgA, (client) => - client.secrets.set({ - params: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgA)) }, + client.connections.create({ payload: { - id: SecretId.make(id), - name: "A token", + owner: "user", + name, + integration: integrationA, + template: TEMPLATE_API_KEY, value: "value-in-a", }, }), ); - // Same user id, different org → distinct user-org scope. The - // secret written in org A must not be visible when the same user - // logs into org B. + // Same user id, different org → different tenant. Org A's connection (and + // its integration) must not be visible in org B. const listInB = yield* asUser(userId, orgB, (client) => - client.secrets.list({ - params: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)) }, - }), + client.connections.list({ query: {} }), ); - expect(listInB.map((s) => s.id)).not.toContain(id); + expect(listInB.map((c) => c.name)).not.toContain(storedName); - const statusInB = yield* asUser(userId, orgB, (client) => - client.secrets.status({ - params: { - scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)), - secretId: SecretId.make(id), - }, - }), - ); - expect(statusInB.status).toBe("missing"); - - // Sanity: the original write is still visible under the org-A - // user-org scope. - const statusInA = yield* asUser(userId, orgA, (client) => - client.secrets.status({ - params: { - scopeId: ScopeId.make(testUserOrgScopeId(userId, orgA)), - secretId: SecretId.make(id), - }, - }), - ); - expect(statusInA.status).toBe("resolved"); - }), - ); - - it.effect("secrets.set rejects a scope outside the executor's stack", () => - Effect.gen(function* () { - const organizationId = nextOrgId(); - const userId = nextUserId(); - const foreignOrg = nextOrgId(); - - const result = yield* asUser(userId, organizationId, (client) => - client.secrets - .set({ - params: { scopeId: ScopeId.make(foreignOrg) }, - payload: { - id: SecretId.make("wrong-scope"), - name: "x", - value: "should not land", - }, - }) - .pipe(Effect.result), - ); - expect(Result.isFailure(result)).toBe(true); - - // And nothing landed in the foreign org — a fresh session pointed - // at that org must not see `wrong-scope`. - const foreignUser = nextUserId(); - const leaked = yield* asUser(foreignUser, foreignOrg, (client) => - client.secrets.status({ - params: { - scopeId: ScopeId.make(foreignOrg), - secretId: SecretId.make("wrong-scope"), - }, - }), + // Sanity: still visible under org A's user-owner list. + const listInA = yield* asUser(userId, orgA, (client) => + client.connections.list({ query: { integration: integrationA, owner: "user" } }), ); - expect(leaked.status).toBe("missing"); + expect(listInA.map((c) => c.name)).toContain(storedName); }), ); }); diff --git a/apps/cloud/src/testing/api-harness.ts b/apps/cloud/src/testing/api-harness.ts index c26cfab80..74397067c 100644 --- a/apps/cloud/src/testing/api-harness.ts +++ b/apps/cloud/src/testing/api-harness.ts @@ -4,15 +4,19 @@ // every real plugin (openapi / mcp / graphql / workos-vault), with // two test-only swaps: // -// - `OrgAuthLive` is replaced with `FakeOrgAuthLive`, which reads -// the scope id off `x-test-org-id` instead of the WorkOS cookie. +// - Auth is faked: the executor binds `{ tenant, subject }` read off the +// `x-test-org-id` / `x-test-user-id` headers instead of the WorkOS cookie. // - `workos-vault` is configured with an in-memory `WorkOSVaultClient` -// so secret writes never reach WorkOS's real API. +// so connection writes never reach WorkOS's real API. // // Tests get a `fetchForOrg(organizationId)` they can hand to `FetchHttpClient` // and then call `HttpApiClient.make(ProtectedCloudApi)` against it. // Each test picks its own org id (usually a random UUID) so rows don't // collide across tests. +// +// v2: the executor is bound to a tenant (the organization id) and a subject +// (the account id). The org-shared catalog is `owner: "org"`; a member's own +// connections are `owner: "user"`. There is no scope stack and no scope id. import { Effect, Layer } from "effect"; import { HttpApiBuilder, HttpApiClient, HttpApiSwagger } from "effect/unstable/httpapi"; @@ -27,7 +31,7 @@ import { } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { createExecutor, makeUserOrgScopeStack, userOrgScopeId } from "@executor-js/sdk"; +import { createExecutor, Subject, Tenant } from "@executor-js/sdk"; import { makeTestWorkOSVaultClient } from "@executor-js/plugin-workos-vault/testing"; import executorConfig from "../../executor.config"; @@ -41,14 +45,14 @@ export const TEST_BASE_URL = "http://test.local"; export const TEST_ORG_HEADER = "x-test-org-id"; export const TEST_USER_HEADER = "x-test-user-id"; -// `asOrg(organizationId, …)` callers don't care which specific user they are, only -// that the executor has a valid user-org scope. We give each org a stable -// default user so list/get operations at the org scope remain deterministic -// across calls within a single test. +// `asOrg(organizationId, …)` callers don't care which specific user they are, +// only that the executor has a bound subject so `owner: "user"` operations work. +// We give each org a stable default subject so list/get operations remain +// deterministic across calls within a single test. const defaultUserFor = (organizationId: string) => `default_user_${organizationId}`; // --------------------------------------------------------------------------- -// Executor factory — mirrors apps/cloud/services/executor#createScopedExecutor +// Executor factory — mirrors `makeScopedExecutor` (binds `{ tenant, subject }`) // but with an in-memory test vault client (see // `@executor-js/plugin-workos-vault/testing`). // --------------------------------------------------------------------------- @@ -59,11 +63,7 @@ const testPlugins = executorConfig.plugins({ }); const testHttpClientLayer = FetchHttpClient.layer; -const createTestScopedExecutor = ( - userId: string, - organizationId: string, - organizationName: string, -) => +const createTestScopedExecutor = (userId: string, organizationId: string) => Effect.gen(function* () { const { db } = yield* DbService; const plugins = testPlugins; @@ -73,13 +73,19 @@ const createTestScopedExecutor = ( namespace: "executor_cloud", provider: "postgresql", }); - const scopes = makeUserOrgScopeStack(userId, organizationId, organizationName); return yield* createExecutor({ - scopes, + tenant: Tenant.make(organizationId), + subject: Subject.make(userId), db: fuma.db, plugins, httpClientLayer: testHttpClientLayer, onElicitation: "accept-all", + // EXPLICIT OAuth callback — production derives + // `${webBaseUrl}${CLOUD_MOUNT_PREFIX}/oauth/callback` in `makeScopedExecutor` + // (the cloud mounts the API under `/api`); the harness wires the matching + // stable test equivalent so the OAuth `start` (authorization_code) flow + // returns a redirect instead of failing loudly on the now-required redirectUri. + redirectUri: "https://test.executor.sh/api/oauth/callback", }); }); @@ -119,8 +125,7 @@ const TestExecutionStackMiddleware = HttpRouter.middleware<{ typeof userHeader === "string" && userHeader.length > 0 ? userHeader : defaultUserFor(organizationId); - const organizationName = `Org ${organizationId}`; - const executor = yield* createTestScopedExecutor(userId, organizationId, organizationName); + const executor = yield* createTestScopedExecutor(userId, organizationId); const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor(), @@ -202,10 +207,9 @@ export const asOrg = ( return yield* body(client); }).pipe(Effect.provide(clientLayerForOrg(organizationId))) as Effect.Effect; -// Same as `asOrg` but also threads a specific user id through the fake -// OrgAuth, so the built executor's user-org scope id is -// `user-org:${userId}:${organizationId}`. Use this for tests that care about -// per-user isolation inside the same org. +// Same as `asOrg` but also threads a specific user id through the fake auth, so +// the built executor's bound subject is `userId`. Use this for tests that care +// about per-user isolation (`owner: "user"` connections) inside the same org. export const asUser = ( userId: string, organizationId: string, @@ -216,10 +220,5 @@ export const asUser = ( return yield* body(client); }).pipe(Effect.provide(clientLayerForUser(userId, organizationId))) as Effect.Effect; -// Exposed so tests can build the same user-org scope id the harness uses -// when writing at a specific user's scope. -export const testUserOrgScopeId = (userId: string, organizationId: string) => - userOrgScopeId(userId, organizationId); - // Re-exports so call sites don't need a second import. export { ProtectedCloudApi }; diff --git a/apps/cloud/src/testing/e2e-stub.ts b/apps/cloud/src/testing/e2e-stub.ts new file mode 100644 index 000000000..1c9ee9e60 --- /dev/null +++ b/apps/cloud/src/testing/e2e-stub.ts @@ -0,0 +1,21 @@ +// Env-gated stub layers that turn `vite dev` into a fully-stubbed, logged-in +// instance — one target every surface (browser / API / MCP / CLI) can drive, +// replacing the bespoke in-process harnesses + e2e-server wiring. +// +// Enabled by `EXECUTOR_E2E_STUB=1`. NEVER set in production — when unset, the +// served route composition is byte-for-byte the real `*Live` layers. +import { WorkOSTestLayer, makeWorkOSTestState } from "../auth/workos.test-layer"; +import { AutumnTestLayer, makeAutumnTestState } from "../extensions/billing/service.test-layer"; + +export const E2E_STUB = process.env.EXECUTOR_E2E_STUB === "1"; + +// Multi-user stub WorkOS: the `wos-session` cookie value IS the user id, with +// per-user membership buckets — so each test/browser picks a fresh user and is +// isolated on the one shared instance (no reset). Swapped in for +// `CoreSharedServices`, it authenticates every surface (session routes, +// /account/me, SSR) with no real WorkOS. +const workos = makeWorkOSTestState({ memberships: [], multiUser: true }); +const autumn = makeAutumnTestState({}); // no paid subscription → free plan → 3-org limit applies + +export const E2EStubWorkOSLayer = WorkOSTestLayer(workos); +export const E2EStubAutumnLayer = AutumnTestLayer(autumn); diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 0c3c2cf6a..7901c3202 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -15,7 +15,7 @@ import { SupportSlot } from "./components/support-slot"; // --------------------------------------------------------------------------- const navItems = [ - ...defaultShellNavItems, + ...defaultShellNavItems.filter((item) => item.to !== "/secrets"), { to: "/org", label: "Organization" }, { to: "/billing", label: "Billing" }, ]; diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md index de10ea312..7bb764e79 100644 --- a/apps/desktop/CHANGELOG.md +++ b/apps/desktop/CHANGELOG.md @@ -1,6 +1,34 @@ -# @executor-js/desktop changelog +# @executor-js/desktop -This file exists for `changesets/action@v1` compatibility (it reads every -workspace package's `CHANGELOG.md` to build the Version Packages PR). -Canonical user-facing release notes are at `apps/cli/release-notes/next.md` -and on the GitHub Releases page. +## 1.5.2 + +## 1.5.1 + +## 1.5.0 + +### Minor Changes + +- [`c7bb2a4`](https://github.com/RhysSullivan/executor/commit/c7bb2a4da99aac4199b424d6d52e6ea843250e3a) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Integrations and connections rework. + + **Highlights** + - Sources are now split into integrations (the API surface) and connections (the credential). One integration can hold many connections — workspace-shared or personal — and each connection gets its own tool catalog. + - Tool addresses carry the connection, so agents can target a specific account: `tools.vercel_api.org.workspace.deploy` vs `tools.vercel_api.user.personal.deploy`. + - Existing data migrates automatically on first launch: sources become integrations, secrets and credential bindings become connections, OAuth apps and tool policies carry over, and the previous database is kept as a backup next to the new one. + - Public no-auth servers (MCP, GraphQL) connect without entering a credential. + - Connections display the signed-in identity, so you can tell accounts apart at a glance. + - The CLI, local web app, and desktop app can connect to a shared Executor server instead of each running their own; the desktop app persists server profiles across restarts. + - Self-hosted Executor now publishes a multi-architecture GHCR image at `ghcr.io/rhyssullivan/executor-selfhost` (stable releases tagged `latest`, prereleases tagged `beta`). + + **Reliability** + - OpenAPI, GraphQL, and MCP tools return structured authentication failures with recovery guidance instead of opaque internal errors — covering missing credentials, expired OAuth connections, upstream 401/403 responses, and MCP per-user isolation. + - OAuth popups complete more reliably in Chrome by preserving the callback channel through the same-origin completion page. + - OAuth Dynamic Client Registration data is reused across retries and reconnects, including scopes, so providers are not asked to register duplicate clients. + - Creating a connection with invalid input (no credential for a credentialed method, mixed input origins) returns a clear error with the reason instead of an opaque internal error. + - The v1 → v2 migration creates connections for no-auth sources, derives OAuth authorize endpoints when v1 only stored a bare issuer origin, keys inline header values per source, and skips malformed credential bindings with a warning instead of silently dropping them. An unreachable OAuth metadata endpoint no longer blocks the migration on launch. + - Google sources use a bundled OpenAPI flow with valid schemas. + - MCP tool output schemas match the actual invocation result envelope, including `content`, `structuredContent`, `_meta`, and `isError`. + - Integration icons survive migration, connected presets show their icons, and credentials show a loading badge while resolving. + + **Breaking changes** + - Tool addresses gained two segments for the connection's owner and name: `tools.vercel_api.deploy` is now `tools.vercel_api.org.workspace.deploy`. Saved tool policies are rewritten automatically during migration; agent code that hard-codes v1.4 addresses needs the new shape (`tools.search()` returns ready-to-call paths). + - The Google Discovery plugin was removed. Google integrations now go through the bundled Google flow; existing Google sources migrate automatically. diff --git a/apps/desktop/electron-builder.config.ts b/apps/desktop/electron-builder.config.ts index 626da1d68..47594f8c0 100644 --- a/apps/desktop/electron-builder.config.ts +++ b/apps/desktop/electron-builder.config.ts @@ -43,8 +43,14 @@ const config: Configuration = { entitlementsInherit: "build/entitlements.mac.plist", notarize: true, }, + // Same arch rule as mac (see comment above): never pin `arch:` in the + // target objects. The win/linux pins used to force both archs out of a + // single x64 matrix leg, embedding an x64 sidecar binary inside the + // "arm64" installers — DOA on linux-arm64, emulated on win-arm64. Each + // workflow leg's --x64/--arm64 flag decides what gets built, so an arm64 + // artifact only exists once a leg stages an arm64 sidecar for it. win: { - target: [{ target: "nsis", arch: ["x64", "arm64"] }], + target: ["nsis"], }, nsis: { oneClick: true, @@ -52,11 +58,7 @@ const config: Configuration = { }, linux: { category: "Development", - target: [ - { target: "AppImage", arch: ["x64", "arm64"] }, - { target: "deb", arch: ["x64", "arm64"] }, - { target: "rpm", arch: ["x64", "arm64"] }, - ], + target: ["AppImage", "deb", "rpm"], }, publish: { provider: "github", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cd3d3b5f4..915a0b390 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/desktop", - "version": "1.4.33", + "version": "1.5.2", "private": true, "homepage": "https://github.com/RhysSullivan/executor", "license": "MIT", diff --git a/apps/desktop/scripts/build-sidecar.ts b/apps/desktop/scripts/build-sidecar.ts index 27fd460e9..d800063b0 100644 --- a/apps/desktop/scripts/build-sidecar.ts +++ b/apps/desktop/scripts/build-sidecar.ts @@ -37,6 +37,112 @@ const targetIsWindows = BUN_TARGET.includes("windows") || process.platform === " const binaryName = targetIsWindows ? "executor-sidecar.exe" : "executor-sidecar"; const sidecarBinary = resolve(SIDECAR_OUT_DIR, binaryName); +/** + * Normalized `-[-]` key for the compile target, derived from + * BUN_TARGET (`bun` = the runner's own platform). Matches the keys used by + * apps/cli/src/build.ts's native-binding maps. + */ +const targetKey = + BUN_TARGET === "bun" + ? `${process.platform}-${process.arch}` + : BUN_TARGET.replace(/^bun-/, "").replace(/^windows-/, "win32-"); + +const targetIsCurrentPlatform = targetKey === `${process.platform}-${process.arch}`; + +// `bun build --compile` does not bundle `.node` native addons into bunfs, so +// the sidecar's eager `require('@libsql/')` (and the keychain plugin's +// lazy keyring load) would fail at runtime. We stage each binding next to the +// binary; src/sidecar/native-bindings.ts (the sidecar's first import) points +// the loaders at them via EXECUTOR_LIBSQL_NATIVE_PATH / +// EXECUTOR_KEYRING_NATIVE_PATH. Mirrors apps/cli/src/build.ts. +const LIBSQL_NATIVE_VERSION = "0.5.29"; +const resolveLibsqlNative = (): string => { + const platformMap: Record = { + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + // The compiled binary runs on Bun, which libSQL's loader treats as glibc + // (its musl->gnu workaround), so non-musl linux targets need the -gnu binding. + "linux-arm64": "linux-arm64-gnu", + "linux-x64": "linux-x64-gnu", + "win32-arm64": "win32-arm64-msvc", + "win32-x64": "win32-x64-msvc", + }; + const target = platformMap[targetKey]; + if (!target) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal + throw new Error(`No @libsql native binding mapping for target ${targetKey}`); + } + const pkg = `@libsql/${target}`; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time resolution falls back to bun's store layout + try { + const req = createRequire(join(APPS_LOCAL, "package.json")); + return join(dirname(req.resolve(`${pkg}/package.json`)), "index.node"); + } catch { + const bunPath = join( + REPO_ROOT, + `node_modules/.bun/${pkg.replace("/", "+")}@${LIBSQL_NATIVE_VERSION}/node_modules/${pkg}/index.node`, + ); + if (!existsSync(bunPath)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal + throw new Error( + `Cannot resolve ${pkg} for the sidecar. Run \`bun install --cpu=* --os=*\` so cross-target native bindings are present.`, + ); + } + return bunPath; + } +}; + +const resolveKeyringNative = (): string => { + const platformMap: Record = { + "darwin-arm64": { + pkg: "@napi-rs/keyring-darwin-arm64", + node: "keyring.darwin-arm64.node", + }, + "darwin-x64": { + pkg: "@napi-rs/keyring-darwin-x64", + node: "keyring.darwin-x64.node", + }, + "linux-arm64": { + pkg: "@napi-rs/keyring-linux-arm64-gnu", + node: "keyring.linux-arm64-gnu.node", + }, + "linux-x64": { + pkg: "@napi-rs/keyring-linux-x64-gnu", + node: "keyring.linux-x64-gnu.node", + }, + "win32-arm64": { + pkg: "@napi-rs/keyring-win32-arm64-msvc", + node: "keyring.win32-arm64-msvc.node", + }, + "win32-x64": { + pkg: "@napi-rs/keyring-win32-x64-msvc", + node: "keyring.win32-x64-msvc.node", + }, + }; + const entry = platformMap[targetKey]; + if (!entry) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal + throw new Error(`No @napi-rs/keyring native binding mapping for target ${targetKey}`); + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: build-time resolution falls back to bun's store layout + try { + const req = createRequire(join(REPO_ROOT, "node_modules", "@napi-rs/keyring", "package.json")); + return join(dirname(req.resolve(`${entry.pkg}/package.json`)), entry.node); + } catch { + const bunPath = join( + REPO_ROOT, + `node_modules/.bun/${entry.pkg.replace("/", "+")}@1.2.0/node_modules/${entry.pkg}/${entry.node}`, + ); + if (!existsSync(bunPath)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal + throw new Error( + `Cannot resolve ${entry.pkg} for the sidecar. Run \`bun install --cpu=* --os=*\` so cross-target native bindings are present.`, + ); + } + return bunPath; + } +}; + // QuickJS ships its WASM as a side asset; `bun build --compile` can't pull // it into bunfs, so we stage it next to the binary and the sidecar entry // preloads it via `setQuickJSModule` before any server import. @@ -54,11 +160,15 @@ const resolveQuickJsWasmPath = (): string => { return wasmPath; }; -// Drizzle's migrator takes a folder path at runtime. The compiled sidecar -// cannot rely on apps/local/drizzle existing on disk, so inline every migration -// as text and let apps/local extract them to a temp folder during startup. +// The v1→v2 data migration replays the legacy v1 drizzle chain +// (apps/local/drizzle-legacy-v1) before reading a legacy database. The +// compiled sidecar cannot rely on that folder existing on disk, so inline +// every migration as text and let apps/local extract them to a temp folder +// during startup. Mirrors apps/cli/src/build.ts — embedding the wrong dir +// (e.g. the v2 chain in drizzle/) makes the sidecar treat every real legacy +// database as "history does not match" and skip the replay. const createEmbeddedMigrationsSource = async () => { - const migrationsDir = resolve(APPS_LOCAL, "drizzle"); + const migrationsDir = resolve(APPS_LOCAL, "drizzle-legacy-v1"); const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: migrationsDir }))) .map((file) => file.replaceAll("\\", "/")) .sort(); @@ -86,6 +196,28 @@ if (!existsSync(APPS_LOCAL_DIST)) { ); } +// Cross-target builds (e.g. the mac x64 leg on an arm64 runner) need the other +// platform's optional native packages on disk before we can stage them. +// `--cpu=* --os=*` extracts them all without modifying the lockfile. Mirrors +// apps/cli/src/build.ts — Bun.spawn, not Bun.$, because the shell +// glob-expands the bare `*` in `--cpu=*` and fails with "no matches found". +if (!targetIsCurrentPlatform) { + console.log("[build-sidecar] installing optional native deps for all platforms..."); + const proc = Bun.spawn(["bun", "install", "--frozen-lockfile", "--cpu=*", "--os=*"], { + cwd: REPO_ROOT, + stdio: ["ignore", "inherit", "inherit"], + }); + if ((await proc.exited) !== 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: build-time fatal + throw new Error("bun install --cpu=* --os=* failed"); + } +} + +// Resolve the native bindings up front so a missing platform package fails the +// build before the (slow) compile, and cross-target builds get a clear message. +const libsqlNativePath = resolveLibsqlNative(); +const keyringNativePath = resolveKeyringNative(); + await rm(SIDECAR_OUT_DIR, { recursive: true, force: true }); await rm(WEB_UI_OUT_DIR, { recursive: true, force: true }); await mkdir(SIDECAR_OUT_DIR, { recursive: true }); @@ -108,6 +240,10 @@ try { console.log(`[build-sidecar] staging QuickJS WASM → ${SIDECAR_OUT_DIR}`); await cp(resolveQuickJsWasmPath(), join(SIDECAR_OUT_DIR, "emscripten-module.wasm")); + console.log(`[build-sidecar] staging native bindings (${targetKey}) → ${SIDECAR_OUT_DIR}`); + await cp(libsqlNativePath, join(SIDECAR_OUT_DIR, "libsql.node")); + await cp(keyringNativePath, join(SIDECAR_OUT_DIR, "keyring.node")); + console.log(`[build-sidecar] staging web UI → ${WEB_UI_OUT_DIR}`); await cp(APPS_LOCAL_DIST, WEB_UI_OUT_DIR, { recursive: true }); } finally { diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index 663e6cd80..801224607 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -26,7 +26,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const ROOT = resolve(import.meta.dir, ".."); -const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle"); +const APPS_LOCAL_DRIZZLE = resolve(ROOT, "../local/drizzle-legacy-v1"); const BINARY = resolve( ROOT, "resources/sidecar", @@ -37,9 +37,11 @@ const AUTH_PASSWORD = "smoke-test-password"; const AUTH_HEADER = `Basic ${btoa(`executor:${AUTH_PASSWORD}`)}`; const READY_TIMEOUT_MS = 30_000; +// Throw instead of process.exit so main()'s finally still tears down the +// spawned sidecar + temp dirs — exiting here leaks a running sidecar process. const fail = (msg: string): never => { - console.error(`[smoke-sidecar] FAIL: ${msg}`); - process.exit(1); + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: standalone smoke harness surfaces failures as a thrown error + throw new Error(`[smoke-sidecar] FAIL: ${msg}`); }; type ToolCallResult = Awaited>; @@ -53,56 +55,49 @@ const makeScopeId = (cwd: string): string => { return `${folder}-${hash}`; }; -const readLegacyMigrationHashes = async (): Promise => { +const readLegacyMigrations = async (): Promise => { const journal = (await Bun.file(join(APPS_LOCAL_DRIZZLE, "meta/_journal.json")).json()) as { readonly entries: readonly { readonly idx: number; readonly tag: string }[]; }; - const hashes: string[] = []; + const migrations: { sql: string; hash: string }[] = []; for (const entry of [...journal.entries].sort((left, right) => left.idx - right.idx)) { const query = await Bun.file(join(APPS_LOCAL_DRIZZLE, `${entry.tag}.sql`)).text(); - hashes.push(createHash("sha256").update(query).digest("hex")); + migrations.push({ + sql: query, + hash: createHash("sha256").update(query).digest("hex"), + }); } - return hashes; + return migrations; }; const seedLegacyScopedSqlite = async (dataDir: string, scopeId: string): Promise => { const db = new Database(join(dataDir, "data.db")); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness closes the SQLite handle before spawning the sidecar try { + // Replay the real legacy v1 chain so the fixture matches the migration + // history the sidecar's embedded copy expects. A hand-rolled partial + // schema that claims the full history is applied makes the v1→v2 data + // migration read tables that don't exist. + const migrations = await readLegacyMigrations(); + for (const migration of migrations) { + // `--> statement-breakpoint` markers are SQL line comments, so the + // whole file executes as one multi-statement batch. + db.exec(migration.sql); + } + db.exec(` - CREATE TABLE source ( - scope_id TEXT NOT NULL, - id TEXT NOT NULL, - plugin_id TEXT NOT NULL, - kind TEXT NOT NULL, - name TEXT NOT NULL, - url TEXT, - can_remove INTEGER NOT NULL, - can_refresh INTEGER NOT NULL, - can_edit INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - CREATE TABLE blob ( - namespace TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - PRIMARY KEY (namespace, key) - ); CREATE TABLE "__drizzle_migrations" ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, hash text NOT NULL, created_at numeric ); `); - const insertMigration = db.prepare( `INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)`, ); - for (const hash of await readLegacyMigrationHashes()) { - insertMigration.run(hash, Date.now()); + for (const migration of migrations) { + insertMigration.run(migration.hash, Date.now()); } db.prepare( @@ -133,22 +128,29 @@ const seedLegacyScopedSqlite = async (dataDir: string, scopeId: string): Promise } }; -const assertLegacyImportCompleted = async (dataDir: string): Promise => { - const markerPath = join(dataDir, "fumadb-sqlite-imported"); - if (!(await Bun.file(markerPath).exists())) { - fail(`legacy SQLite import marker was not written at ${markerPath}`); +// The v1→v2 migration moves the legacy SQLite file set aside as +// `data.db.v1-v2--` before writing the new v2 database. Assert the +// backup exists and still holds the seeded legacy row — that proves the +// migration path ran (rather than the sidecar treating the DB as fresh) and +// preserved the original data. +const assertV1MigrationCompleted = async (dataDir: string): Promise => { + const entries = await Array.fromAsync(new Bun.Glob("data.db.v1-v2-*").scan({ cwd: dataDir })); + const backups = entries.filter((name) => !name.endsWith("-wal") && !name.endsWith("-shm")); + if (backups.length !== 1) { + fail(`expected exactly one v1→v2 backup in ${dataDir}, found: ${JSON.stringify(entries)}`); } - const marker = (await Bun.file(markerPath).json()) as { - readonly importedRows?: number; - readonly importedTables?: readonly string[]; - readonly backupPath?: string; - }; - if ((marker.importedRows ?? 0) < 2 || !marker.importedTables?.includes("source")) { - fail(`legacy SQLite import marker has unexpected contents: ${JSON.stringify(marker)}`); - } - if (!marker.backupPath || !(await Bun.file(marker.backupPath).exists())) { - fail(`legacy SQLite backup was not preserved: ${JSON.stringify(marker)}`); + const backup = new Database(join(dataDir, backups[0]!), { readonly: true }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness closes the SQLite handle it opened + try { + const row = backup.prepare("SELECT name FROM source WHERE id = 'legacy-smoke'").get() as { + name?: string; + } | null; + if (row?.name !== "Legacy Smoke Source") { + fail(`v1→v2 backup is missing the seeded legacy source row: ${JSON.stringify(row)}`); + } + } finally { + backup.close(); } }; @@ -327,6 +329,14 @@ const main = async () => { const scopeDir = await mkdtemp(join(tmpdir(), "executor-smoke-scope-")); const dataDir = await mkdtemp(join(tmpdir(), "executor-smoke-data-")); await seedLegacyScopedSqlite(dataDir, makeScopeId(scopeDir)); + // v2 connections reference credentials by provider item instead of carrying + // raw values, so seed the file-secrets provider (auth.json under + // XDG_DATA_HOME) with the token the sandbox's connections.create points at. + const xdgDir = await mkdtemp(join(tmpdir(), "executor-smoke-xdg-")); + await Bun.write( + join(xdgDir, "executor", "auth.json"), + `${JSON.stringify({ "petstore-token": "smoke-token" })}\n`, + ); const openapi = startOpenApiServer(); console.log(`[smoke-sidecar] scope: ${scopeDir}`); @@ -342,6 +352,7 @@ const main = async () => { EXECUTOR_AUTH_PASSWORD: AUTH_PASSWORD, EXECUTOR_SCOPE_DIR: scopeDir, EXECUTOR_DATA_DIR: dataDir, + XDG_DATA_HOME: xdgDir, }, stdin: "ignore", stdout: "pipe", @@ -364,12 +375,14 @@ const main = async () => { await rm(scopeDir, { recursive: true, force: true }).catch(() => {}); // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness await rm(dataDir, { recursive: true, force: true }).catch(() => {}); + // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort tempdir cleanup in a standalone smoke harness + await rm(xdgDir, { recursive: true, force: true }).catch(() => {}); }; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone smoke harness needs a finally to tear down the spawned binary + http server try { const port = await waitForReadyPort(proc); - await assertLegacyImportCompleted(dataDir); + await assertV1MigrationCompleted(dataDir); const mcpUrl = new URL(`http://127.0.0.1:${port}/mcp`); console.log(`[smoke-sidecar] ready on ${mcpUrl.origin}`); @@ -385,11 +398,8 @@ const main = async () => { const hasResume = tools.tools.some((t) => t.name === "resume"); if (!hasResume) fail(`MCP tools/list missing "resume": ${JSON.stringify(tools.tools)}`); - // Drive the running OpenAPI server through a multi-step orchestration - // in one execute. Covers: source registration, array list response, path - // param dispatch, and object responses — all going out over real HTTP from - // inside QuickJS. - const code = ` + // Shared sandbox helper, prepended to both execute calls. + const unwrapHelper = ` const unwrapToolData = (value) => { if (value && typeof value === "object" && "ok" in value) { if (!value.ok) throw new Error(value.error?.message ?? "Tool failed"); @@ -398,16 +408,46 @@ const unwrapToolData = (value) => { if (value && typeof value === "object" && "data" in value) return value.data; return value; }; -await tools.executor.openapi.addSource({ +`; + + // Execute #1 — register the integration and create its connection. v2 + // produces tools per connection, and the sandbox snapshots the tool tree + // when an execution starts, so the invocation has to happen in a second + // execute call. + const setupCode = `${unwrapHelper} +unwrapToolData(await tools.executor.openapi.addSpec({ spec: { kind: "url", url: ${JSON.stringify(`${openapi.origin}/openapi.json`)} }, - name: "Petstore Smoke API", baseUrl: ${JSON.stringify(openapi.origin)}, - namespace: "petstore", -}); -const listResult = await tools.petstore.pets.listPets({}); -const list = unwrapToolData(listResult); -const fetched = await tools.petstore.pets.getPet({ petId: list[1].id }); -const fetchedData = unwrapToolData(fetched); + slug: "petstore", +})); +unwrapToolData(await tools.executor.coreTools.connections.create({ + owner: "org", + name: "main", + integration: "petstore", + template: "apiKey", + from: { provider: "file", id: "petstore-token" }, +})); +return "setup-ok"; +`; + + const setupResult = await completePausedResult( + client, + await client.callTool({ + name: "execute", + arguments: { code: setupCode }, + }), + ); + if (setupResult.result !== "setup-ok") { + fail(`integration setup failed: ${JSON.stringify(setupResult)}`); + } + + // Execute #2 — drive the running OpenAPI server. Covers per-connection + // tool registration, array list response, path param dispatch, and object + // responses — all going out over real HTTP from inside QuickJS, via the + // v2 `tools....` address. + const invokeCode = `${unwrapHelper} +const list = unwrapToolData(await tools.petstore.org.main.pets.listPets({})); +const fetchedData = unwrapToolData(await tools.petstore.org.main.pets.getPet({ petId: list[1].id })); return { count: list.length, names: list.map((p) => p.name), @@ -415,7 +455,10 @@ return { }; `; - const result = await client.callTool({ name: "execute", arguments: { code } }); + const result = await client.callTool({ + name: "execute", + arguments: { code: invokeCode }, + }); const structured = await completePausedResult(client, result); const expected = { count: 2, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 7dfb6aaa6..2340668c7 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -53,16 +53,33 @@ let authHeaderUnsubscribe: (() => void) | null = null; const PRELOAD_PATH = fileURLToPath(new URL("../preload/index.js", import.meta.url)); +const liveMainWindow = (): BrowserWindow | null => { + const window = mainWindow; + if (!window) return null; + if (window.isDestroyed()) { + mainWindow = null; + return null; + } + return window; +}; + +const focusMainWindow = () => { + const window = liveMainWindow(); + if (!window) { + if (connection) void createWindow(connection); + return; + } + if (window.isMinimized()) window.restore(); + if (!window.isVisible()) window.show(); + window.focus(); +}; + const ensureSingleInstance = () => { if (!app.requestSingleInstanceLock()) { app.quit(); return false; } - app.on("second-instance", () => { - if (!mainWindow) return; - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - }); + app.on("second-instance", focusMainWindow); return true; }; @@ -76,7 +93,10 @@ const installBasicAuthHeader = (origin: string, password: string | null) => { { urls: [`${origin}/*`] }, (details, callback) => { callback({ - requestHeaders: { ...details.requestHeaders, Authorization: headerValue }, + requestHeaders: { + ...details.requestHeaders, + Authorization: headerValue, + }, }); }, ); @@ -142,7 +162,7 @@ const createWindow = async (conn: SidecarConnection) => { const linuxIcon = resolveLinuxIcon(); - mainWindow = new BrowserWindow({ + const window = new BrowserWindow({ x: windowState.x, y: windowState.y, width: windowState.width, @@ -160,12 +180,19 @@ const createWindow = async (conn: SidecarConnection) => { sandbox: true, }, }); + mainWindow = window; - windowState.manage(mainWindow); + windowState.manage(window); - mainWindow.once("ready-to-show", () => mainWindow?.show()); + window.once("closed", () => { + if (mainWindow === window) mainWindow = null; + }); - mainWindow.webContents.setWindowOpenHandler(({ url, disposition }) => { + window.once("ready-to-show", () => { + if (!window.isDestroyed()) window.show(); + }); + + window.webContents.setWindowOpenHandler(({ url, disposition }) => { // JS-initiated `window.open(url, name, "popup=1,...")` calls (OAuth // sign-in flow in packages/react/src/api/oauth-popup.ts:73) come in // with disposition "new-window" — allow them as Electron child @@ -196,7 +223,7 @@ const createWindow = async (conn: SidecarConnection) => { return { action: "deny" }; }); - await mainWindow.loadURL(conn.baseUrl); + await window.loadURL(conn.baseUrl); }; const showPortInUseDialog = async (port: number) => { @@ -210,6 +237,10 @@ const showPortInUseDialog = async (port: number) => { }); }; +// Last non-port-conflict sidecar startup failure, surfaced by boot() in a +// user-facing dialog instead of letting the app vanish without a window. +let lastSidecarStartError: unknown = null; + const startWithCurrentSettings = async (): Promise => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: bind failures surface as a user-facing dialog try { @@ -220,6 +251,7 @@ const startWithCurrentSettings = async (): Promise => await showPortInUseDialog(error.port); return null; } + lastSidecarStartError = error; log.error("Failed to start executor sidecar", error); return null; } @@ -237,7 +269,8 @@ const restartSidecarAndReload = async (): Promise => { } connection = next; installBasicAuthHeader(next.baseUrl, next.authPassword); - if (mainWindow) await mainWindow.loadURL(next.baseUrl); + const window = liveMainWindow(); + if (window) await window.loadURL(next.baseUrl); return toDesktopServerConnection(next); }; @@ -404,13 +437,41 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => { } }; +// A sidecar that can't boot usually means a broken build or an incompatible +// data dir — both states the user can't see from a dock icon that bounces +// once and disappears. Surface the real error, and stage any available update +// so a dead-on-arrival release can heal itself: without the explicit check +// here, the boot-time update check never runs (it sits after a successful +// sidecar start), so a broken app could never self-update its way out. +const handleFatalSidecarFailure = async (error: unknown) => { + if (app.isPackaged) { + // Install whatever finishes downloading by the time the user quits the + // failure dialog; if it downloads while the dialog is open, the regular + // 'update-downloaded' prompt offers an immediate restart instead. + autoUpdater.autoInstallOnAppQuit = true; + void runUpdateCheck({ alertOnFail: false }); + } + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: sidecar startup failures arrive as plain Node errors and render in a native dialog + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); + await dialog.showMessageBox({ + type: "error", + title: "Executor failed to start", + message: "The local Executor server crashed during startup.", + detail: `${detail.slice(0, 1800)}\n\nFull log: ${log.transports.file.getFile().path}`, + buttons: ["Quit"], + }); +}; + const installApplicationMenu = () => { const isMac = process.platform === "darwin"; const appMenu: MenuItemConstructorOptions = { label: app.name, submenu: [ { role: "about" }, - { label: "Check for Updates…", click: () => void runUpdateCheck({ alertOnFail: true }) }, + { + label: "Check for Updates…", + click: () => void runUpdateCheck({ alertOnFail: true }), + }, { type: "separator" }, ...(isMac ? ([ @@ -442,10 +503,14 @@ const boot = async () => { registerIpcHandlers(); connection = await startWithCurrentSettings(); if (!connection) { - // Even when the sidecar can't start, open the window so the user - // reaches Settings to change the port. Pointing at the (unreachable) - // baseUrl would just show ECONNREFUSED — a placeholder URL would be - // worse. For now: quit with the dialog already shown. + // Port conflicts already showed their dialog inside + // startWithCurrentSettings; every other failure surfaces here so the app + // never silently bounces-and-vanishes. Pointing a window at the + // (unreachable) baseUrl would just show ECONNREFUSED — a placeholder URL + // would be worse. For now: explain, offer the updater a chance, quit. + if (lastSidecarStartError != null) { + await handleFatalSidecarFailure(lastSidecarStartError); + } app.quit(); return; } @@ -464,8 +529,7 @@ if (ensureSingleInstance()) { }); app.on("activate", () => { - if (!connection) return; - if (BrowserWindow.getAllWindows().length === 0) void createWindow(connection); + focusMainWindow(); }); app.on("before-quit", async (event) => { diff --git a/apps/desktop/src/main/sidecar.ts b/apps/desktop/src/main/sidecar.ts index f777b4d6e..6dc42e963 100644 --- a/apps/desktop/src/main/sidecar.ts +++ b/apps/desktop/src/main/sidecar.ts @@ -238,6 +238,7 @@ export async function startSidecar(options: StartOptions = {}): Promise')` / keyring walk inside +// the binary fails. build-sidecar.ts copies each platform's `.node` next to +// the executable (`libsql.node`, `keyring.node`); here we publish their +// on-disk paths via env vars the loaders read. Mirrors apps/cli/src/native-bindings.ts. +// +// This MUST be the FIRST import in server.ts. ES modules evaluate every import +// before the importer's own body, and libSQL resolves its native addon EAGERLY +// at module load (`const {...} = requireNative()` in `libsql/index.js`). So the +// env var has to be set as a side effect of an import that is ordered before +// the `@executor-js/local` → `@libsql/client` graph — setting it in server.ts's +// body would run too late, after libSQL had already tried (and failed) to load. +// --------------------------------------------------------------------------- + +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const execDir = dirname(process.execPath); + +// libSQL: our `libsql` patch reads EXECUTOR_LIBSQL_NATIVE_PATH and loads the +// colocated binding directly, before its (in-bunfs, doomed) platform-package walk. +const libsqlNodeOnDisk = join(execDir, "libsql.node"); +if ( + typeof Bun !== "undefined" && + !process.env.EXECUTOR_LIBSQL_NATIVE_PATH && + existsSync(libsqlNodeOnDisk) +) { + process.env.EXECUTOR_LIBSQL_NATIVE_PATH = libsqlNodeOnDisk; +} + +// keyring: the keychain plugin reads EXECUTOR_KEYRING_NATIVE_PATH (lazily, but +// set here alongside libSQL so all native colocation lives in one place). We +// can't use NAPI_RS_NATIVE_LIBRARY_PATH — @napi-rs/keyring 1.2.0's env-var +// branch assigns to a local that gets overwritten before the binding returns. +const keyringNodeOnDisk = join(execDir, "keyring.node"); +if ( + typeof Bun !== "undefined" && + !process.env.EXECUTOR_KEYRING_NATIVE_PATH && + existsSync(keyringNodeOnDisk) +) { + process.env.EXECUTOR_KEYRING_NATIVE_PATH = keyringNodeOnDisk; +} diff --git a/apps/desktop/src/sidecar/server.ts b/apps/desktop/src/sidecar/server.ts index 3eb31db1e..392acd67b 100644 --- a/apps/desktop/src/sidecar/server.ts +++ b/apps/desktop/src/sidecar/server.ts @@ -7,6 +7,9 @@ * announces readiness with the resolved port on stdout so the Electron * main process can attach a BrowserWindow to it. */ +// MUST stay the first import — points libSQL/keyring at the `.node` bindings +// staged next to the compiled binary before `@executor-js/local` loads them. +import "./native-bindings"; import { dirname, join } from "node:path"; // Pre-load QuickJS WASM for compiled binaries. `bun build --compile` can't diff --git a/apps/host-cloudflare/CHANGELOG.md b/apps/host-cloudflare/CHANGELOG.md index 8f5f5c719..918a0ebd6 100644 --- a/apps/host-cloudflare/CHANGELOG.md +++ b/apps/host-cloudflare/CHANGELOG.md @@ -1,6 +1 @@ -# @executor-js/host-cloudflare changelog - -This file exists for `changesets/action@v1` compatibility (it reads every -workspace package's `CHANGELOG.md` to build the Version Packages PR). -Canonical user-facing release notes are at `apps/cli/release-notes/next.md` -and on the GitHub Releases page. +# @executor-js/host-cloudflare diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 3c7d63402..37f2c1176 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,6 +15,7 @@ import { unstable_dev, type Unstable_DevWorker } from "wrangler"; // --------------------------------------------------------------------------- const dir = fileURLToPath(new URL(".", import.meta.url)); +const runId = randomUUID().slice(0, 8); // Inline spec (no network); registers one tool, exercising the D1 write path. const SPEC = JSON.stringify({ @@ -21,7 +23,9 @@ const SPEC = JSON.stringify({ info: { title: "Test", version: "1.0.0" }, servers: [{ url: "https://example.com" }], paths: { - "/ping": { get: { operationId: "ping", responses: { "200": { description: "ok" } } } }, + "/ping": { + get: { operationId: "ping", responses: { "200": { description: "ok" } } }, + }, }, }); @@ -63,7 +67,12 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { body: JSON.stringify({ code: "export default 6 * 7" }), }); expect(res.status).toBe(200); - const body = (await res.json()) as { text: string; isError: boolean }; + const body = (await res.json()) as { + status: string; + text: string; + isError: boolean; + }; + expect(body.status).toBe("completed"); expect(body.isError).toBe(false); expect(body.text).toBe("42"); }, 60_000); @@ -92,14 +101,15 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { }); expect(largeSpec.length).toBeGreaterThan(900_000); - const add = await worker.fetch("/api/scopes/default/openapi/specs", { + const slug = `largeapi-${runId}`; + const add = await worker.fetch("/api/openapi/specs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ spec: { kind: "blob", value: largeSpec }, - name: "Large API", + slug, + description: "Large API", baseUrl: "https://example.com", - namespace: "largeapi", }), }); expect(add.status).toBe(200); @@ -107,37 +117,45 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(added.toolCount).toBe(250); // Reads back through the R2 rehydration path (the >800KB blob lives in R2). - const got = await worker.fetch("/api/scopes/default/openapi/sources/largeapi"); + const got = await worker.fetch(`/api/openapi/integrations/${slug}`); expect(got.status).toBe(200); - const source = (await got.json()) as { namespace: string } | null; - expect(source?.namespace).toBe("largeapi"); + const integration = (await got.json()) as { slug: string } | null; + expect(integration?.slug).toBe(slug); }, 90_000); it("adds an OpenAPI source and reads it back (D1 write + read path)", async () => { - const add = await worker.fetch("/api/scopes/default/openapi/specs", { + const slug = `testapi-${runId}`; + const add = await worker.fetch("/api/openapi/specs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ spec: { kind: "blob", value: SPEC }, - name: "Test API", + slug, + description: "Test API", baseUrl: "https://example.com", - namespace: "testapi", }), }); expect(add.status).toBe(200); - const added = (await add.json()) as { toolCount: number; namespace: string }; + const added = (await add.json()) as { toolCount: number; slug: string }; expect(added.toolCount).toBeGreaterThan(0); - const got = await worker.fetch("/api/scopes/default/openapi/sources/testapi"); + const got = await worker.fetch(`/api/openapi/integrations/${slug}`); expect(got.status).toBe(200); - const source = (await got.json()) as { namespace: string } | null; - expect(source?.namespace).toBe("testapi"); + const integration = (await got.json()) as { slug: string } | null; + expect(integration?.slug).toBe(slug); }, 60_000); it("gates the API when dev-auth is on but treats the request as the dev admin", async () => { - // dev-auth means the request is the fixed dev admin; /api/scope resolves. - const res = await worker.fetch("/api/scope"); + // dev-auth means the request is the fixed dev admin; a gated route resolves + // to the principal. There is no scope stack in v2 — account/me is the + // identity-backed read that the API gate protects. + const res = await worker.fetch("/api/account/me"); expect(res.status).toBe(200); + const me = (await res.json()) as { + user: { id: string }; + organization: { id: string }; + }; + expect(me.user.id).toBe("dev"); }); it("lists tools on a follow-up request after a fresh initialize (DO session survives across requests)", async () => { @@ -172,9 +190,16 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { const sessionId = init.headers.get("mcp-session-id"); expect(sessionId).toBeTruthy(); - await rpc(sessionId, { jsonrpc: "2.0", method: "notifications/initialized" }); + await rpc(sessionId, { + jsonrpc: "2.0", + method: "notifications/initialized", + }); - const list = await rpc(sessionId, { jsonrpc: "2.0", id: 2, method: "tools/list" }); + const list = await rpc(sessionId, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + }); expect(list.status).toBe(200); const listed = (await list.json()) as { result?: { tools?: ReadonlyArray<{ name: string }> }; @@ -210,7 +235,10 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { const sessionId = init.headers.get("mcp-session-id"); expect(sessionId).toBeTruthy(); - await rpc(sessionId, { jsonrpc: "2.0", method: "notifications/initialized" }); + await rpc(sessionId, { + jsonrpc: "2.0", + method: "notifications/initialized", + }); const call = await rpc(sessionId, { jsonrpc: "2.0", diff --git a/apps/host-cloudflare/web/routeTree.gen.ts b/apps/host-cloudflare/web/routeTree.gen.ts index 706fc9d11..4e9e39fa4 100644 --- a/apps/host-cloudflare/web/routeTree.gen.ts +++ b/apps/host-cloudflare/web/routeTree.gen.ts @@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsRouteImport } from './routes/tools' import { Route as SecretsRouteImport } from './routes/secrets' import { Route as PoliciesRouteImport } from './routes/policies' -import { Route as ConnectionsRouteImport } from './routes/connections' import { Route as IndexRouteImport } from './routes/index' import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' @@ -34,11 +33,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ path: '/policies', getParentRoute: () => rootRouteImport, } as any) -const ConnectionsRoute = ConnectionsRouteImport.update({ - id: '/connections', - path: '/connections', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -67,7 +61,6 @@ const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute @@ -78,7 +71,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute @@ -90,7 +82,6 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute @@ -103,7 +94,6 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/connections' | '/policies' | '/secrets' | '/tools' @@ -114,7 +104,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' - | '/connections' | '/policies' | '/secrets' | '/tools' @@ -125,7 +114,6 @@ export interface FileRouteTypes { id: | '__root__' | '/' - | '/connections' | '/policies' | '/secrets' | '/tools' @@ -137,7 +125,6 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - ConnectionsRoute: typeof ConnectionsRoute PoliciesRoute: typeof PoliciesRoute SecretsRoute: typeof SecretsRoute ToolsRoute: typeof ToolsRoute @@ -170,13 +157,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PoliciesRouteImport parentRoute: typeof rootRouteImport } - '/connections': { - id: '/connections' - path: '/connections' - fullPath: '/connections' - preLoaderRoute: typeof ConnectionsRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -217,7 +197,6 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - ConnectionsRoute: ConnectionsRoute, PoliciesRoute: PoliciesRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, diff --git a/apps/host-cloudflare/web/routes/__root.tsx b/apps/host-cloudflare/web/routes/__root.tsx index acd51a330..d60d69b1d 100644 --- a/apps/host-cloudflare/web/routes/__root.tsx +++ b/apps/host-cloudflare/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { Toaster } from "@executor-js/react/components/sonner"; import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; @@ -56,16 +57,27 @@ function AuthGate({ children }: { children: ReactNode }) { ); } +function AuthenticatedApp() { + const auth = useAuth(); + const organizationId = auth.status === "authenticated" ? (auth.organization?.id ?? null) : null; + + return ( + + + + + + + + + ); +} + function RootComponent() { return ( - - - - - - + ); diff --git a/apps/host-cloudflare/web/routes/connections.tsx b/apps/host-cloudflare/web/routes/connections.tsx deleted file mode 100644 index ae9f0af5a..000000000 --- a/apps/host-cloudflare/web/routes/connections.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ConnectionsPage } from "@executor-js/react/pages/connections"; - -export const Route = createFileRoute("/connections")({ - component: () => , -}); diff --git a/apps/host-cloudflare/web/routes/index.tsx b/apps/host-cloudflare/web/routes/index.tsx index 01273b87a..2d57f82f0 100644 --- a/apps/host-cloudflare/web/routes/index.tsx +++ b/apps/host-cloudflare/web/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { SourcesPage } from "@executor-js/react/pages/sources"; +import { IntegrationsPage } from "@executor-js/react/pages/integrations"; export const Route = createFileRoute("/")({ - component: SourcesPage, + component: IntegrationsPage, }); diff --git a/apps/host-cloudflare/web/routes/secrets.tsx b/apps/host-cloudflare/web/routes/secrets.tsx index cdf46a221..10bd4a10b 100644 --- a/apps/host-cloudflare/web/routes/secrets.tsx +++ b/apps/host-cloudflare/web/routes/secrets.tsx @@ -1,25 +1,10 @@ -import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { SecretsPage } from "@executor-js/react/pages/secrets"; -// Query params supported by the agent-facing `secrets.create` static tool: -// it builds a URL like `/secrets?name=…&scope=…&secretId=…` and hands -// it to the user. The page opens the add modal pre-filled when any -// prefill field is present so the user only has to type the value. -const SearchParams = Schema.toStandardSchemaV1( - Schema.Struct({ - name: Schema.optional(Schema.String), - secretId: Schema.optional(Schema.String), - provider: Schema.optional(Schema.String), - scope: Schema.optional(Schema.String), - }), -); - +// The Providers/Secrets page lets self-host users inspect their credential +// backends. Credential entry happens through the per-integration Add Account +// flow (`connections.createHandoff` → `/integrations/{slug}?addAccount=1`), +// not here, so this route takes no search params. export const Route = createFileRoute("/secrets")({ - validateSearch: SearchParams, - component: () => { - const { name, secretId, provider, scope } = Route.useSearch(); - const hasPrefill = name != null || secretId != null; - return ; - }, + component: () => , }); diff --git a/apps/host-cloudflare/web/routes/sources.$namespace.tsx b/apps/host-cloudflare/web/routes/sources.$namespace.tsx index 2bcdcce73..b8fcd190b 100644 --- a/apps/host-cloudflare/web/routes/sources.$namespace.tsx +++ b/apps/host-cloudflare/web/routes/sources.$namespace.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; -import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; +import { IntegrationDetailPage } from "@executor-js/react/pages/integration-detail"; export const Route = createFileRoute("/sources/$namespace")({ component: () => { const { namespace } = Route.useParams(); - return ; + return ; }, }); diff --git a/apps/host-cloudflare/web/routes/sources.add.$pluginKey.tsx b/apps/host-cloudflare/web/routes/sources.add.$pluginKey.tsx index a1618a00a..9924b2c9e 100644 --- a/apps/host-cloudflare/web/routes/sources.add.$pluginKey.tsx +++ b/apps/host-cloudflare/web/routes/sources.add.$pluginKey.tsx @@ -1,6 +1,6 @@ import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; -import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; +import { AddIntegrationPage } from "@executor-js/react/pages/integration-add"; const SearchParams = Schema.toStandardSchemaV1( Schema.Struct({ @@ -14,6 +14,6 @@ export const Route = createFileRoute("/sources/add/$pluginKey")({ component: () => { const { pluginKey } = Route.useParams(); const { url, preset } = Route.useSearch(); - return ; + return ; }, }); diff --git a/apps/host-selfhost/CHANGELOG.md b/apps/host-selfhost/CHANGELOG.md index 208ebc58c..3c24028be 100644 --- a/apps/host-selfhost/CHANGELOG.md +++ b/apps/host-selfhost/CHANGELOG.md @@ -1,6 +1,52 @@ -# @executor-js/host-selfhost changelog +# @executor-js/host-selfhost -This file exists for `changesets/action@v1` compatibility (it reads every -workspace package's `CHANGELOG.md` to build the Version Packages PR). -Canonical user-facing release notes are at `apps/cli/release-notes/next.md` -and on the GitHub Releases page. +## 0.0.3 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/runtime-quickjs@1.5.2 + - @executor-js/execution@1.5.2 + - @executor-js/plugin-graphql@1.5.2 + - @executor-js/plugin-mcp@1.5.2 + - @executor-js/plugin-openapi@1.5.2 + - @executor-js/app@1.4.4 + - @executor-js/api@1.4.24 + - @executor-js/host-mcp@1.4.4 + - @executor-js/plugin-encrypted-secrets@0.0.3 + - @executor-js/react@1.4.24 + +## 0.0.2 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + - @executor-js/runtime-quickjs@1.5.1 + - @executor-js/execution@1.5.1 + - @executor-js/plugin-graphql@1.5.1 + - @executor-js/plugin-mcp@1.5.1 + - @executor-js/plugin-openapi@1.5.1 + - @executor-js/app@1.4.4 + - @executor-js/api@1.4.23 + - @executor-js/host-mcp@1.4.4 + - @executor-js/plugin-encrypted-secrets@0.0.2 + - @executor-js/react@1.4.23 + +## 0.0.1 + +### Patch Changes + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad), [`9c9bcb6`](https://github.com/RhysSullivan/executor/commit/9c9bcb663e48ebb21a71f8058812319c1ec2a242)]: + - @executor-js/sdk@1.5.0 + - @executor-js/plugin-openapi@1.5.0 + - @executor-js/execution@1.5.0 + - @executor-js/plugin-graphql@1.5.0 + - @executor-js/plugin-mcp@1.5.0 + - @executor-js/runtime-quickjs@1.5.0 + - @executor-js/app@1.4.4 + - @executor-js/api@1.4.22 + - @executor-js/host-mcp@1.4.4 + - @executor-js/plugin-encrypted-secrets@0.0.1 + - @executor-js/react@1.4.22 diff --git a/apps/host-selfhost/Dockerfile b/apps/host-selfhost/Dockerfile index 2d24328bd..18b3c4f01 100644 --- a/apps/host-selfhost/Dockerfile +++ b/apps/host-selfhost/Dockerfile @@ -32,6 +32,9 @@ RUN rm -rf node_modules && bun install --frozen-lockfile --production --ignore-s # ── Runtime stage: serve the built app under Bun ──────────────────────────── FROM oven/bun:1 AS runtime WORKDIR /app +LABEL org.opencontainers.image.source="https://github.com/RhysSullivan/executor" \ + org.opencontainers.image.description="Single-container self-hosted Executor" \ + org.opencontainers.image.licenses="MIT" ENV NODE_ENV=production \ EXECUTOR_HOST=0.0.0.0 \ PORT=4788 \ diff --git a/apps/host-selfhost/README.md b/apps/host-selfhost/README.md index 41f997cd8..7969d27f5 100644 --- a/apps/host-selfhost/README.md +++ b/apps/host-selfhost/README.md @@ -7,6 +7,18 @@ external database, worker, or proxy. ## Run it +Using the published image: + +```bash +docker run -d \ + --name executor-selfhost \ + -p 4788:4788 \ + -v executor-data:/data \ + ghcr.io/rhyssullivan/executor-selfhost:latest +``` + +Or build from a repository clone: + ```bash # From this directory: docker compose up -d --build diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index d967253a9..df78a5c6b 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -1,11 +1,12 @@ { "name": "@executor-js/host-selfhost", - "version": "0.0.0", + "version": "0.0.3", "private": true, "type": "module", "exports": { ".": "./src/index.ts", - "./serve": "./src/serve.ts" + "./serve": "./src/serve.ts", + "./plugins": "./src/plugins.ts" }, "scripts": { "dev": "bunx --bun vite dev", diff --git a/apps/host-selfhost/src/admin/invites.node.test.ts b/apps/host-selfhost/src/admin/invites.node.test.ts index 1b20f2888..697103246 100644 --- a/apps/host-selfhost/src/admin/invites.node.test.ts +++ b/apps/host-selfhost/src/admin/invites.node.test.ts @@ -58,11 +58,15 @@ test("a code minted via the admin API redeems into a real org membership", async const token = res.headers.get("set-auth-token") ?? ""; expect(token).not.toBe(""); - // The new user resolves to the one org's scope (membership, via the pin). - const scope = await handler( - new Request(`${BASE}/api/scope`, { headers: { authorization: `Bearer ${token}` } }), + // The new user resolves to a real org membership (the bound tenant). + const me = await handler( + new Request(`${BASE}/api/account/me`, { + headers: { authorization: `Bearer ${token}` }, + }), ); - expect(scope.status).toBe(200); + expect(me.status).toBe(200); + const meBody = (await me.json()) as { organization: { id: string } | null }; + expect(meBody.organization?.id).toBeTruthy(); // The single-use code is now spent: reusing it is rejected. const reuse = await signUp({ diff --git a/apps/host-selfhost/src/api/api.ts b/apps/host-selfhost/src/api/api.ts new file mode 100644 index 000000000..fc15061c4 --- /dev/null +++ b/apps/host-selfhost/src/api/api.ts @@ -0,0 +1,6 @@ +// Dev-server API entry. `vite.config.ts`'s `executorApiPlugin` dynamically +// imports THIS module (via a computed specifier) at request time under +// `bunx --bun vite dev`, kept separate from the static config graph so Vite's +// Node-based config loader does not follow `@executor-js/host-mcp`'s +// extensionless re-exports (which resolve under Bun, not Node ESM). +export { makeSelfHostApiHandler } from "../app"; diff --git a/apps/host-selfhost/src/auth/better-auth.test.ts b/apps/host-selfhost/src/auth/better-auth.test.ts index beef0c503..968a6902d 100644 --- a/apps/host-selfhost/src/auth/better-auth.test.ts +++ b/apps/host-selfhost/src/auth/better-auth.test.ts @@ -26,7 +26,9 @@ test("migrations create both the Better Auth and FumaDB executor schema regions" // invariant: there is no shared in-process handle anymore, yet a row Better // Auth wrote is immediately visible here on the same file: URL. const { createClient } = await import("@libsql/client"); - const db = createClient({ url: `file:${join(process.env.EXECUTOR_DATA_DIR!, "data.db")}` }); + const db = createClient({ + url: `file:${join(process.env.EXECUTOR_DATA_DIR!, "data.db")}`, + }); const names = (await db.execute("SELECT name FROM sqlite_master WHERE type='table'")).rows.map( // oxlint-disable-next-line executor/no-redundant-primitive-cast -- boundary: sqlite_master.name is TEXT; narrow libSQL's SQLValue to string for the table-name list (r) => r.name as string, @@ -35,8 +37,9 @@ test("migrations create both the Better Auth and FumaDB executor schema regions" for (const t of ["user", "session", "account", "organization", "member"]) { expect(names).toContain(t); } - // FumaDB executor tables coexist in the same file - expect(names).toContain("secret"); + // FumaDB executor tables coexist in the same file (v2: a connection IS the + // credential, so the `connection` table replaces the v1 `secret` table). + expect(names).toContain("connection"); // CROSS-CONNECTION PROOF: the bootstrap admin Better Auth wrote through its // LibsqlDialect connection is readable through this independent connection. @@ -51,7 +54,7 @@ test("migrations create both the Better Auth and FumaDB executor schema regions" db.close(); }); -test("sign-up issues a bearer token and resolves to a per-user org-pinned scope", async () => { +test("sign-up issues a bearer token and resolves to a per-user org-pinned identity", async () => { const inviteCode = await mintInviteCode(handler); const signUp = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { @@ -69,20 +72,65 @@ test("sign-up issues a bearer token and resolves to a per-user org-pinned scope" const token = signUp.headers.get("set-auth-token"); expect(token).toBeTruthy(); - const scoped = await handler( - new Request("http://localhost/api/scope", { headers: { authorization: `Bearer ${token}` } }), + // The bearer token resolves to the user pinned to their own org (the v2 binding + // is `{ tenant: org, subject: user }`; `/api/account/me` reflects both). + const me = await handler( + new Request("http://localhost/api/account/me", { + headers: { authorization: `Bearer ${token}` }, + }), + ); + expect(me.status).toBe(200); + const body = (await me.json()) as { + user: { id: string; email: string }; + organization: { id: string; name: string } | null; + }; + expect(body.user.email).toBe("member@test.local"); + expect(body.organization).not.toBeNull(); + expect(body.organization!.id).toBeTruthy(); +}); + +test("self-host API keys are not capped by Better Auth's default request limit", async () => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "key-user@test.local", + password: "member-password-123", + name: "Key User", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); + const token = signUp.headers.get("set-auth-token"); + expect(token).toBeTruthy(); + + const createKey = await handler( + new Request(`${BASE}/api/account/api-keys`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ name: "MCP bootstrap" }), + }), ); - expect(scoped.status).toBe(200); - const body = (await scoped.json()) as { id: string; stack: ReadonlyArray<{ id: string }> }; - expect(body.stack.length).toBe(2); - const inner = body.stack[0]!; - const outer = body.stack[1]!; - expect(outer.id).toBe(body.id); - expect(inner.id.startsWith("user-org:")).toBe(true); - expect(inner.id.endsWith(`:${outer.id}`)).toBe(true); + expect(createKey.status).toBe(200); + const keyBody = (await createKey.json()) as { value: string }; + + for (let i = 0; i < 12; i++) { + const me = await handler( + new Request(`${BASE}/api/account/me`, { + headers: { "x-api-key": keyBody.value }, + }), + ); + expect(me.status).toBe(200); + } }); test("an unauthenticated request is rejected with 401", async () => { - const res = await handler(new Request("http://localhost/api/scope")); + const res = await handler(new Request("http://localhost/api/account/me")); expect(res.status).toBe(401); }); diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 578304090..7a7fc861a 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -83,7 +83,7 @@ const makeAuthOptions = (url: string, organizationId: string, gate?: SignupGate) plugins: [ organization(), admin(), - apiKey({ enableSessionForAPIKeys: true }), + apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }), bearer(), mcp({ loginPage: "/login" }), ], diff --git a/apps/host-selfhost/src/boot.test.ts b/apps/host-selfhost/src/boot.test.ts index 4a29bc09a..58dbde0bc 100644 --- a/apps/host-selfhost/src/boot.test.ts +++ b/apps/host-selfhost/src/boot.test.ts @@ -19,12 +19,58 @@ const { handler, dispose } = await makeSelfHostTestApp({ }); afterAll(() => dispose()); -test("GET /scope returns the single-admin org scope stack", async () => { - const res = await handler(new Request("http://localhost/api/scope")); - expect(res.status).toBe(200); - const body = (await res.json()) as { id: string; stack: ReadonlyArray<{ id: string }> }; - expect(body.id).toBe("default-org"); - expect(body.stack.map((s) => s.id)).toEqual(["user-org:admin:default-org", "default-org"]); +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { + operationId: "httpGet", + summary: "GET", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +test("the single-admin binding resolves the org tenant for connection reads", async () => { + // The connections surface is authenticated and reads the per-request executor's + // (tenant, subject) binding. Registering an integration + org connection and + // reading it back proves the single-admin identity resolves to a live executor + // bound to its org tenant (the v2 successor to the old /api/scope probe). + const add = await handler( + new Request("http://localhost/api/openapi/specs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + spec: { kind: "blob", value: TINY_SPEC }, + slug: "tiny", + baseUrl: "", + }), + }), + ); + expect(add.status).toBe(200); + + const created = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + owner: "org", + name: "main", + integration: "tiny", + template: "bearer", + value: "token", + }), + }), + ); + expect(created.status).toBe(200); + + const list = await handler(new Request("http://localhost/api/connections")); + expect(list.status).toBe(200); + const connections = (await list.json()) as ReadonlyArray<{ address: string }>; + expect(connections.some((c) => c.address === "tools.tiny.org.main")).toBe(true); }); test("POST /executions runs code in the QuickJS sandbox", async () => { @@ -36,7 +82,11 @@ test("POST /executions runs code in the QuickJS sandbox", async () => { }), ); expect(res.status).toBe(200); - const body = (await res.json()) as { status: string; text: string; isError: boolean }; + const body = (await res.json()) as { + status: string; + text: string; + isError: boolean; + }; expect(body.status).toBe("completed"); expect(body.text).toBe("42"); expect(body.isError).toBe(false); diff --git a/apps/host-selfhost/src/multi-user.test.ts b/apps/host-selfhost/src/multi-user.test.ts index 544b913e1..b70535608 100644 --- a/apps/host-selfhost/src/multi-user.test.ts +++ b/apps/host-selfhost/src/multi-user.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; +import { connectionIdentifier } from "@executor-js/sdk/shared"; import { mintInviteCode } from "./testing/mint-invite"; @@ -18,6 +19,22 @@ const { handler, dispose } = await makeSelfHostApiHandler(); afterAll(() => dispose()); const BASE = "http://localhost:4788"; +const connectionName = (name: string): string => String(connectionIdentifier(name)); + +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { + operationId: "httpGet", + summary: "GET", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); const signUp = async (email: string): Promise => { const inviteCode = await mintInviteCode(handler); @@ -25,7 +42,12 @@ const signUp = async (email: string): Promise => { new Request(`${BASE}/api/auth/sign-up/email`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ email, password: "password-12345678", name: email, inviteCode }), + body: JSON.stringify({ + email, + password: "password-12345678", + name: email, + inviteCode, + }), }), ); expect(res.status).toBe(200); @@ -34,67 +56,132 @@ const signUp = async (email: string): Promise => { return token; }; -const scopeOf = async (token: string): Promise<{ userScope: string; orgScope: string }> => { +const orgIdOf = async (token: string): Promise => { const res = await handler( - new Request(`${BASE}/api/scope`, { headers: { authorization: `Bearer ${token}` } }), + new Request(`${BASE}/api/account/me`, { + headers: { authorization: `Bearer ${token}` }, + }), ); expect(res.status).toBe(200); - const body = (await res.json()) as { stack: ReadonlyArray<{ id: string }> }; - return { userScope: body.stack[0]!.id, orgScope: body.stack[1]!.id }; + const body = (await res.json()) as { organization: { id: string } }; + return body.organization.id; }; -const setSecret = (token: string, scopeId: string, id: string, value: string) => +const addIntegration = (token: string, slug: string) => + handler( + new Request(`${BASE}/api/openapi/specs`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + spec: { kind: "blob", value: TINY_SPEC }, + slug, + baseUrl: "", + }), + }), + ); + +const createConnection = ( + token: string, + body: { + owner: "org" | "user"; + name: string; + integration: string; + template: string; + value: string; + }, +) => handler( - new Request(`${BASE}/api/scopes/${scopeId}/secrets`, { + new Request(`${BASE}/api/connections`, { method: "POST", - headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, - body: JSON.stringify({ id, name: id, value }), + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), }), ); -const secretResolves = async (token: string, scopeId: string, id: string): Promise => { +const connectionAddresses = async (token: string): Promise => { const res = await handler( - new Request(`${BASE}/api/scopes/${scopeId}/secrets/${id}/status`, { + new Request(`${BASE}/api/connections`, { headers: { authorization: `Bearer ${token}` }, }), ); - if (res.status !== 200) return false; - const body = (await res.json()) as { status: string }; - return body.status === "resolved"; + expect(res.status).toBe(200); + const body = (await res.json()) as ReadonlyArray<{ address: string }>; + return body.map((c) => c.address); }; const runCode = async (token: string, code: string) => { const res = await handler( new Request(`${BASE}/api/executions`, { method: "POST", - headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, body: JSON.stringify({ code }), }), ); return res; }; -test("multiple accounts share one org but isolate per-user secrets", async () => { +test("multiple accounts share one org but isolate per-user connections", async () => { const alice = await signUp("alice@multi.test"); const bob = await signUp("bob@multi.test"); - const a = await scopeOf(alice); - const b = await scopeOf(bob); - - // Same single org, distinct personal (user-org) scopes. - expect(a.orgScope).toBe(b.orgScope); - expect(a.userScope).not.toBe(b.userScope); - - // Alice stores a personal secret on her user-org scope. - expect((await setSecret(alice, a.userScope, "gh", "alice-token")).status).toBe(200); - - // Alice can resolve her own personal secret; Bob cannot see it. - expect(await secretResolves(alice, a.userScope, "gh")).toBe(true); - expect(await secretResolves(bob, a.userScope, "gh")).toBe(false); - - // Org-scoped secrets ARE shared across members of the one org. - expect((await setSecret(alice, a.orgScope, "org-key", "shared-value")).status).toBe(200); - expect(await secretResolves(bob, a.orgScope, "org-key")).toBe(true); + // Same single org for both members. + const aliceOrg = await orgIdOf(alice); + const bobOrg = await orgIdOf(bob); + expect(aliceOrg).toBe(bobOrg); + + // The integration is tenant-scoped; register it once. + expect((await addIntegration(alice, "tiny")).status).toBe(200); + + // Alice attaches a USER-owned connection (private to her) and an ORG-owned + // connection (shared across the tenant). + expect( + ( + await createConnection(alice, { + owner: "user", + name: "alice-private", + integration: "tiny", + template: "bearer", + value: "alice-token", + }) + ).status, + ).toBe(200); + expect( + ( + await createConnection(alice, { + owner: "org", + name: "team-shared", + integration: "tiny", + template: "bearer", + value: "shared-token", + }) + ).status, + ).toBe(200); + + // Alice sees both her user connection and the org connection. + const aliceConns = await connectionAddresses(alice); + expect( + aliceConns.some((a) => a.includes("user") && a.includes(connectionName("alice-private"))), + ).toBe(true); + expect( + aliceConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared"))), + ).toBe(true); + + // Bob — a different user in the SAME org — sees the org connection but NOT + // Alice's user-owned one. + const bobConns = await connectionAddresses(bob); + expect(bobConns.some((a) => a.includes("org") && a.includes(connectionName("team-shared")))).toBe( + true, + ); + expect(bobConns.some((a) => a.includes(connectionName("alice-private")))).toBe(false); }); test("each account can execute code in its own scoped sandbox", async () => { diff --git a/apps/host-selfhost/src/scope-isolation.test.ts b/apps/host-selfhost/src/scope-isolation.test.ts index 317a6cfd4..4880c0c25 100644 --- a/apps/host-selfhost/src/scope-isolation.test.ts +++ b/apps/host-selfhost/src/scope-isolation.test.ts @@ -3,53 +3,127 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; +import { connectionIdentifier } from "@executor-js/sdk/shared"; process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-iso-")); // Identity comes from request headers so a single handler can serve many -// distinct identities concurrently — the setup that would expose a -// cross-fiber scope leak if the executor's scope were shared rather than -// request-scoped. +// distinct identities concurrently — the setup that would expose a cross-fiber +// identity leak if the per-request executor's binding were shared rather than +// request-scoped. Each identity is its own (org, user): in v2 the org is the +// tenant (catalog partition) and the user is the acting subject (drives +// `owner: "user"` rows). const { makeSelfHostTestApp, headerIdentityLayer } = await import("./testing/test-app"); -const { handler, dispose } = await makeSelfHostTestApp({ identity: headerIdentityLayer }); +const { handler, dispose } = await makeSelfHostTestApp({ + identity: headerIdentityLayer, +}); afterAll(() => dispose()); -const getScope = async (userId: string, organizationId: string) => { +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { + operationId: "httpGet", + summary: "GET", + responses: { "200": { description: "ok" } }, + }, + }, + }, +}); + +const headersFor = (userId: string, organizationId: string): Record => ({ + "x-test-user": userId, + "x-test-org": organizationId, + "content-type": "application/json", +}); + +const connectionNameForUser = (userId: string): string => + String(connectionIdentifier(`conn-${userId}`)); + +// Seed, as the given identity, a tenant-scoped integration plus a user-owned +// connection named after the subject. Tenant isolation means each identity's +// catalog is independent; subject isolation means the user connection is private. +const seedIdentity = async (userId: string, organizationId: string): Promise => { + const headers = headersFor(userId, organizationId); + const add = await handler( + new Request("http://localhost/api/openapi/specs", { + method: "POST", + headers, + body: JSON.stringify({ + spec: { kind: "blob", value: TINY_SPEC }, + slug: "iso", + baseUrl: "", + }), + }), + ); + expect(add.status).toBe(200); + const conn = await handler( + new Request("http://localhost/api/connections", { + method: "POST", + headers, + body: JSON.stringify({ + owner: "user", + name: `conn-${userId}`, + integration: "iso", + template: "bearer", + value: `token-${userId}`, + }), + }), + ); + expect(conn.status).toBe(200); +}; + +const listConnectionAddresses = async ( + userId: string, + organizationId: string, +): Promise<{ status: number; addresses: string[] }> => { const res = await handler( - new Request("http://localhost/api/scope", { + new Request("http://localhost/api/connections", { headers: { "x-test-user": userId, "x-test-org": organizationId }, }), ); - expect(res.status).toBe(200); - return (await res.json()) as { id: string; stack: ReadonlyArray<{ id: string }> }; + if (res.status !== 200) return { status: res.status, addresses: [] }; + const body = (await res.json()) as ReadonlyArray<{ address: string }>; + return { status: res.status, addresses: body.map((c) => c.address) }; }; -test("concurrent requests with distinct identities get disjoint, correct scope stacks", async () => { - // 6 identities × 8 interleaved requests each = 48 concurrent requests over - // the one long-lived SQLite handle. +test("concurrent requests with distinct identities get disjoint, correct executor bindings", async () => { + // 6 identities, each its own (org, user). Seed each sequentially, then fire 48 + // interleaved reads over the one long-lived SQLite handle. const identities = Array.from({ length: 6 }, (_, i) => ({ userId: `user-${i}`, organizationId: `org-${i}`, })); - const requests = Array.from({ length: 48 }, (_, i) => identities[i % identities.length]); - const results = await Promise.all(requests.map((id) => getScope(id.userId, id.organizationId))); + for (const id of identities) { + await seedIdentity(id.userId, id.organizationId); + } - results.forEach((scope, i) => { - const { userId, organizationId } = requests[i]; - // Each response reflects ONLY its own request's identity — no bleed. - expect(scope.id).toBe(organizationId); - expect(scope.stack.map((s) => s.id)).toEqual([ - `user-org:${userId}:${organizationId}`, - organizationId, - ]); + const requests = Array.from({ length: 48 }, (_, i) => identities[i % identities.length]); + const results = await Promise.all( + requests.map((id) => listConnectionAddresses(id.userId, id.organizationId)), + ); + + results.forEach((result, i) => { + const { userId } = requests[i]; + // Each response reflects ONLY its own request's identity — no bleed. The + // subject's own user connection is present, and no OTHER subject's is. + expect(result.status).toBe(200); + expect(result.addresses.some((a) => a.includes(connectionNameForUser(userId)))).toBe(true); + const otherUsers = identities.map((id) => id.userId).filter((u) => u !== userId); + for (const other of otherUsers) { + expect(result.addresses.some((a) => a.includes(connectionNameForUser(other)))).toBe(false); + } }); -}); +}, 15_000); test("a request with no identity is rejected", async () => { - const res = await handler(new Request("http://localhost/api/scope")); - // singleAdmin never returns null, but the header provider does -> the - // middleware's unauthenticated path fires. + const res = await handler(new Request("http://localhost/api/connections")); + // The header provider returns no principal -> the middleware's unauthenticated + // path fires. expect(res.status).toBeGreaterThanOrEqual(400); }); diff --git a/apps/host-selfhost/src/secrets-integration.test.ts b/apps/host-selfhost/src/secrets-integration.test.ts index 3bf9cdb01..276774a12 100644 --- a/apps/host-selfhost/src/secrets-integration.test.ts +++ b/apps/host-selfhost/src/secrets-integration.test.ts @@ -3,52 +3,89 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { Effect, Layer } from "effect"; import { afterAll, expect, test } from "@effect/vitest"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; +import { makeScopedExecutor } from "@executor-js/api/server"; + +import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; +import { SelfHostScopedExecutorSeams } from "./execution"; +import type { SelfHostPlugins } from "./plugins"; + +// In v2 a connection IS the credential: its inline `value` is written through the +// default writable provider — here the encrypted-secrets provider, which stores +// an AES-GCM payload at rest. This test registers an integration, attaches an +// org connection carrying a plaintext needle, and asserts the needle never +// reaches the SQLite file while the versioned "v1." ciphertext does. const dataDir = mkdtempSync(join(tmpdir(), "eh-secrets-")); +const dbPath = join(dataDir, "data.db"); process.env.EXECUTOR_DATA_DIR = dataDir; process.env.EXECUTOR_SECRET_KEY = "integration-test-master-key"; -const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); +const createScopedExecutor = ( + accountId: string, + organizationId: string, + organizationName: string, +) => + makeScopedExecutor(accountId, organizationId, organizationName).pipe( + Effect.provide(SelfHostScopedExecutorSeams), + ); + +const dbHandle = await createSelfHostDb({ + path: dbPath, + namespace: "executor_selfhost", + version: "1.0.0", +}); +const dbLayer = Layer.succeed(SelfHostDb)(dbHandle); +afterAll(() => dbHandle.close()); -const { handler, dispose } = await makeSelfHostTestApp({ - identity: singleAdminIdentityLayer({ - userId: "admin", - organizationId: "default-org", - organizationName: "Default", - }), +const TINY_SPEC = JSON.stringify({ + openapi: "3.0.0", + info: { title: "Tiny", version: "1.0.0" }, + servers: [{ url: "https://httpbin.org" }], + paths: { + "/get": { + get: { + operationId: "httpGet", + summary: "GET", + responses: { "200": { description: "ok" } }, + }, + }, + }, }); -afterAll(() => dispose()); const NEEDLE = "PLAINTEXT_NEEDLE_9f3a"; -test("a secret set via the API is stored encrypted at rest by the 'encrypted' provider", async () => { - const setRes = await handler( - new Request("http://localhost/api/scopes/default-org/secrets", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: "gh-token", name: "GitHub", value: NEEDLE }), - }), +test("a connection value is stored encrypted at rest by the 'encrypted' provider", async () => { + const created = await Effect.runPromise( + Effect.gen(function* () { + const admin = yield* createScopedExecutor("admin", "default-org", "Default"); + yield* admin.openapi.addSpec({ + spec: { kind: "blob", value: TINY_SPEC }, + slug: "tiny", + baseUrl: "", + }); + // The connection's inline `value` is opaque to core (D11) — it is written + // through the default writable provider regardless of the template slug. + return yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("gh"), + integration: IntegrationSlug.make("tiny"), + template: AuthTemplateSlug.make("bearer"), + value: NEEDLE, + }); + }).pipe(Effect.provide(dbLayer), Effect.scoped), ); - expect(setRes.status).toBe(200); - const ref = (await setRes.json()) as { id: string; provider: string }; - expect(ref.id).toBe("gh-token"); - // The first writable provider is the encrypted one — it handled the write. - expect(ref.provider).toBe("encrypted"); - // The status endpoint resolves it (decrypt round-trips through the provider). - const statusRes = await handler( - new Request("http://localhost/api/scopes/default-org/secrets/gh-token/status"), - ); - expect(statusRes.status).toBe(200); - expect(((await statusRes.json()) as { status: string }).status).toBe("resolved"); + // The first writable provider is the encrypted one — it handled the write. + expect(String(created.provider)).toBe("encrypted"); - // Inspect the real SQLite file through a SEPARATE libSQL connection (the app's - // own libSQL client wrote it): the plaintext must NOT appear anywhere, and a - // versioned AES-GCM payload ("v1.") must be present. Reading this file through - // an independent connection also exercises the cross-connection visibility of - // FumaDB's writes. - const db = createClient({ url: `file:${join(dataDir, "data.db")}` }); + // Inspect the real SQLite file through a SEPARATE libSQL connection: the + // plaintext must NOT appear anywhere, and a versioned AES-GCM payload ("v1.") + // must be present. Reading through an independent connection also exercises the + // cross-connection visibility of FumaDB's writes. + const db = createClient({ url: `file:${dbPath}` }); const tables = (await db.execute("SELECT name FROM sqlite_master WHERE type='table'")).rows.map( // oxlint-disable-next-line executor/no-redundant-primitive-cast -- boundary: sqlite_master.name is TEXT; narrow libSQL's SQLValue to string for the table list (r) => r.name as string, diff --git a/apps/host-selfhost/src/sources-mcp.test.ts b/apps/host-selfhost/src/sources-mcp.test.ts index 6954a8740..12ffc944e 100644 --- a/apps/host-selfhost/src/sources-mcp.test.ts +++ b/apps/host-selfhost/src/sources-mcp.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { Effect, Layer } from "effect"; import { afterAll, expect, test } from "@effect/vitest"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; import { makeScopedExecutor } from "@executor-js/api/server"; import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; @@ -25,7 +26,8 @@ const createScopedExecutor = ( Effect.provide(SelfHostScopedExecutorSeams), ); -// End-to-end: an org source is reachable from a user's MCP `execute` sandbox. +// End-to-end: an org-owned connection's tools are reachable from a user's MCP +// `execute` sandbox. const dataDir = mkdtempSync(join(tmpdir(), "eh-srcmcp-")); const dbPath = join(dataDir, "data.db"); process.env.EXECUTOR_DATA_DIR = dataDir; @@ -55,8 +57,10 @@ afterAll(() => dispose()); const BASE = "http://localhost:4788"; const addOrgSource = async (organizationId: string): Promise => { - // Install the source at the (Better Auth) org scope, on its own connection to - // the shared DB file. WAL makes the committed rows visible to the server. + // Register the integration and attach an org-owned connection, on its own + // connection to the shared DB file. WAL makes the committed rows visible to + // the server. Org-owned connections (and their per-connection tools) are + // shared across every member of the tenant. const seedDb = await createSelfHostDb({ path: dbPath, namespace: "executor_selfhost", @@ -67,17 +71,22 @@ const addOrgSource = async (organizationId: string): Promise => { const admin = yield* createScopedExecutor("seed", organizationId, "Default"); yield* admin.openapi.addSpec({ spec: { kind: "blob", value: TINY_SPEC }, - scope: organizationId, - name: "tiny", - namespace: "tiny", + slug: "tiny", baseUrl: "", }); + yield* admin.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: IntegrationSlug.make("tiny"), + template: AuthTemplateSlug.make("none"), + value: "", + }); }).pipe(Effect.provide(Layer.succeed(SelfHostDb)(seedDb)), Effect.scoped), ); await seedDb.close(); }; -test("a user's MCP execute sandbox can reach an org source's tools", async () => { +test("a user's MCP execute sandbox can reach an org-owned connection's tools", async () => { const inviteCode = await mintInviteCode(handler); const su = await handler( new Request(`${BASE}/api/auth/sign-up/email`, { @@ -94,12 +103,14 @@ test("a user's MCP execute sandbox can reach an org source's tools", async () => const token = su.headers.get("set-auth-token") ?? ""; expect(token).not.toBe(""); - // The user's real org scope (Better Auth assigns a random org id). - const scopeRes = await handler( - new Request(`${BASE}/api/scope`, { headers: { authorization: `Bearer ${token}` } }), + // The user's real org id (Better Auth assigns a random org id) — the tenant the + // per-request executor binds to. + const meRes = await handler( + new Request(`${BASE}/api/account/me`, { + headers: { authorization: `Bearer ${token}` }, + }), ); - const organizationId = ((await scopeRes.json()) as { stack: ReadonlyArray<{ id: string }> }) - .stack[1]!.id; + const organizationId = ((await meRes.json()) as { organization: { id: string } }).organization.id; await addOrgSource(organizationId); diff --git a/apps/host-selfhost/src/sources.test.ts b/apps/host-selfhost/src/sources.test.ts index 6eebe845b..eb44674f3 100644 --- a/apps/host-selfhost/src/sources.test.ts +++ b/apps/host-selfhost/src/sources.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { Effect, Layer } from "effect"; import { afterAll, expect, test } from "@effect/vitest"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk"; import { makeScopedExecutor } from "@executor-js/api/server"; import { createSelfHostDb, SelfHostDb } from "./db/self-host-db"; @@ -42,35 +43,48 @@ const TINY_SPEC = JSON.stringify({ servers: [{ url: "https://httpbin.org" }], paths: { "/get": { - get: { operationId: "httpGet", summary: "GET", responses: { "200": { description: "ok" } } }, + get: { + operationId: "httpGet", + summary: "GET", + responses: { "200": { description: "ok" } }, + }, }, }, }); -test("an org-scoped OpenAPI source registers tools shared across org members", async () => { - // Alice (a member) adds a source at the org install scope. +test("an org-owned connection registers tools shared across org members", async () => { + // Alice (a member) uploads the integration spec and attaches an org-owned + // connection. Org-owned connections (and their per-connection tools) are + // visible to every member of the tenant. const added = await Effect.runPromise( Effect.gen(function* () { const alice = yield* createScopedExecutor("alice", "default-org", "Default"); - return yield* alice.openapi.addSpec({ + const result = yield* alice.openapi.addSpec({ spec: { kind: "blob", value: TINY_SPEC }, - scope: "default-org", - name: "tiny", - namespace: "tiny", + slug: "tiny", baseUrl: "", }); + yield* alice.connections.create({ + owner: "org", + name: ConnectionName.make("shared"), + integration: IntegrationSlug.make("tiny"), + template: AuthTemplateSlug.make("none"), + value: "", + }); + return result; }).pipe(Effect.provide(dbLayer), Effect.scoped), ); - expect(added.sourceId).toBe("tiny"); + expect(String(added.slug)).toBe("tiny"); expect(added.toolCount).toBeGreaterThan(0); - // Bob — a different user in the SAME org — sees the org-scoped source's tools. - const bobToolIds = await Effect.runPromise( + // Bob — a different user in the SAME org — sees the org-owned connection's + // tools (addressed `tools.tiny.org.shared.`). + const bobToolAddresses = await Effect.runPromise( Effect.gen(function* () { const bob = yield* createScopedExecutor("bob", "default-org", "Default"); const tools = yield* bob.tools.list(); - return tools.map((tool) => String(tool.id)); + return tools.map((tool) => String(tool.address)); }).pipe(Effect.provide(dbLayer), Effect.scoped), ); - expect(bobToolIds.some((id) => id.startsWith("tiny."))).toBe(true); + expect(bobToolAddresses.some((address) => address.startsWith("tools.tiny.org."))).toBe(true); }); diff --git a/apps/host-selfhost/vite.config.ts b/apps/host-selfhost/vite.config.ts index 2b1aab913..d921ecb74 100644 --- a/apps/host-selfhost/vite.config.ts +++ b/apps/host-selfhost/vite.config.ts @@ -56,7 +56,11 @@ function executorApiPlugin(): Plugin { rawUrl === "/api" || rawUrl.startsWith("/api/") || rawUrl.startsWith("/mcp") || - rawUrl.startsWith("/docs"); + rawUrl.startsWith("/docs") || + // RFC 9728 / RFC 8414 OAuth discovery the MCP client fetches before + // auth. Served by the Effect router in prod; without this the SPA + // index.html fallback answers 200-with-HTML and breaks discovery. + rawUrl.startsWith("/.well-known/"); if (!handled) return next(); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Vite dev middleware must convert handler failures into HTTP 500 responses diff --git a/apps/host-selfhost/web/routeTree.gen.ts b/apps/host-selfhost/web/routeTree.gen.ts index 417e8afd4..a53ccbec3 100644 --- a/apps/host-selfhost/web/routeTree.gen.ts +++ b/apps/host-selfhost/web/routeTree.gen.ts @@ -12,15 +12,16 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsRouteImport } from './routes/tools' import { Route as SecretsRouteImport } from './routes/secrets' import { Route as PoliciesRouteImport } from './routes/policies' -import { Route as ConnectionsRouteImport } from './routes/connections' import { Route as ApiKeysRouteImport } from './routes/api-keys' import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' import { Route as JoinCodeRouteImport } from './routes/join.$code' +import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace' import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' import { Route as PluginsPluginIdSplatRouteImport } from './routes/plugins.$pluginId.$' +import { Route as IntegrationsAddPluginKeyRouteImport } from './routes/integrations.add.$pluginKey' const ToolsRoute = ToolsRouteImport.update({ id: '/tools', @@ -37,11 +38,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ path: '/policies', getParentRoute: () => rootRouteImport, } as any) -const ConnectionsRoute = ConnectionsRouteImport.update({ - id: '/connections', - path: '/connections', - getParentRoute: () => rootRouteImport, -} as any) const ApiKeysRoute = ApiKeysRouteImport.update({ id: '/api-keys', path: '/api-keys', @@ -72,6 +68,11 @@ const JoinCodeRoute = JoinCodeRouteImport.update({ path: '/join/$code', getParentRoute: () => rootRouteImport, } as any) +const IntegrationsNamespaceRoute = IntegrationsNamespaceRouteImport.update({ + id: '/integrations/$namespace', + path: '/integrations/$namespace', + getParentRoute: () => rootRouteImport, +} as any) const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ id: '/sources/add/$pluginKey', path: '/sources/add/$pluginKey', @@ -82,18 +83,25 @@ const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({ path: '/plugins/$pluginId/$', getParentRoute: () => rootRouteImport, } as any) +const IntegrationsAddPluginKeyRoute = + IntegrationsAddPluginKeyRouteImport.update({ + id: '/integrations/add/$pluginKey', + path: '/integrations/add/$pluginKey', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/admin': typeof AdminRoute '/api-keys': typeof ApiKeysRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/join/$code': typeof JoinCodeRoute '/resume/$executionId': typeof ResumeExecutionIdRoute '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } @@ -101,13 +109,14 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/admin': typeof AdminRoute '/api-keys': typeof ApiKeysRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/join/$code': typeof JoinCodeRoute '/resume/$executionId': typeof ResumeExecutionIdRoute '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } @@ -116,13 +125,14 @@ export interface FileRoutesById { '/': typeof IndexRoute '/admin': typeof AdminRoute '/api-keys': typeof ApiKeysRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/join/$code': typeof JoinCodeRoute '/resume/$executionId': typeof ResumeExecutionIdRoute '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } @@ -132,13 +142,14 @@ export interface FileRouteTypes { | '/' | '/admin' | '/api-keys' - | '/connections' | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/join/$code' | '/resume/$executionId' | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' | '/sources/add/$pluginKey' fileRoutesByTo: FileRoutesByTo @@ -146,13 +157,14 @@ export interface FileRouteTypes { | '/' | '/admin' | '/api-keys' - | '/connections' | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/join/$code' | '/resume/$executionId' | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' | '/sources/add/$pluginKey' id: @@ -160,13 +172,14 @@ export interface FileRouteTypes { | '/' | '/admin' | '/api-keys' - | '/connections' | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/join/$code' | '/resume/$executionId' | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' | '/sources/add/$pluginKey' fileRoutesById: FileRoutesById @@ -175,13 +188,14 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AdminRoute: typeof AdminRoute ApiKeysRoute: typeof ApiKeysRoute - ConnectionsRoute: typeof ConnectionsRoute PoliciesRoute: typeof PoliciesRoute SecretsRoute: typeof SecretsRoute ToolsRoute: typeof ToolsRoute + IntegrationsNamespaceRoute: typeof IntegrationsNamespaceRoute JoinCodeRoute: typeof JoinCodeRoute ResumeExecutionIdRoute: typeof ResumeExecutionIdRoute SourcesNamespaceRoute: typeof SourcesNamespaceRoute + IntegrationsAddPluginKeyRoute: typeof IntegrationsAddPluginKeyRoute PluginsPluginIdSplatRoute: typeof PluginsPluginIdSplatRoute SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute } @@ -209,13 +223,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PoliciesRouteImport parentRoute: typeof rootRouteImport } - '/connections': { - id: '/connections' - path: '/connections' - fullPath: '/connections' - preLoaderRoute: typeof ConnectionsRouteImport - parentRoute: typeof rootRouteImport - } '/api-keys': { id: '/api-keys' path: '/api-keys' @@ -258,6 +265,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof JoinCodeRouteImport parentRoute: typeof rootRouteImport } + '/integrations/$namespace': { + id: '/integrations/$namespace' + path: '/integrations/$namespace' + fullPath: '/integrations/$namespace' + preLoaderRoute: typeof IntegrationsNamespaceRouteImport + parentRoute: typeof rootRouteImport + } '/sources/add/$pluginKey': { id: '/sources/add/$pluginKey' path: '/sources/add/$pluginKey' @@ -272,6 +286,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PluginsPluginIdSplatRouteImport parentRoute: typeof rootRouteImport } + '/integrations/add/$pluginKey': { + id: '/integrations/add/$pluginKey' + path: '/integrations/add/$pluginKey' + fullPath: '/integrations/add/$pluginKey' + preLoaderRoute: typeof IntegrationsAddPluginKeyRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -279,13 +300,14 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AdminRoute: AdminRoute, ApiKeysRoute: ApiKeysRoute, - ConnectionsRoute: ConnectionsRoute, PoliciesRoute: PoliciesRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, + IntegrationsNamespaceRoute: IntegrationsNamespaceRoute, JoinCodeRoute: JoinCodeRoute, ResumeExecutionIdRoute: ResumeExecutionIdRoute, SourcesNamespaceRoute: SourcesNamespaceRoute, + IntegrationsAddPluginKeyRoute: IntegrationsAddPluginKeyRoute, PluginsPluginIdSplatRoute: PluginsPluginIdSplatRoute, SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, } diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index aff13915c..c817b6feb 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { Toaster } from "@executor-js/react/components/sonner"; import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; @@ -64,6 +65,22 @@ function AuthGate({ children }: { children: ReactNode }) { return <>{children}; } +function AuthenticatedApp() { + const auth = useAuth(); + const organizationId = auth.status === "authenticated" ? (auth.organization?.id ?? null) : null; + + return ( + + + + + + + + + ); +} + function RootComponent() { const pathname = useRouterState({ select: (s) => s.location.pathname }); @@ -81,12 +98,7 @@ function RootComponent() { return ( - - - - - - + ); diff --git a/apps/host-selfhost/web/routes/connections.tsx b/apps/host-selfhost/web/routes/connections.tsx deleted file mode 100644 index ae9f0af5a..000000000 --- a/apps/host-selfhost/web/routes/connections.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ConnectionsPage } from "@executor-js/react/pages/connections"; - -export const Route = createFileRoute("/connections")({ - component: () => , -}); diff --git a/apps/host-selfhost/web/routes/index.tsx b/apps/host-selfhost/web/routes/index.tsx index 01273b87a..2d57f82f0 100644 --- a/apps/host-selfhost/web/routes/index.tsx +++ b/apps/host-selfhost/web/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { SourcesPage } from "@executor-js/react/pages/sources"; +import { IntegrationsPage } from "@executor-js/react/pages/integrations"; export const Route = createFileRoute("/")({ - component: SourcesPage, + component: IntegrationsPage, }); diff --git a/apps/host-selfhost/web/routes/integrations.$namespace.tsx b/apps/host-selfhost/web/routes/integrations.$namespace.tsx new file mode 100644 index 000000000..49a458104 --- /dev/null +++ b/apps/host-selfhost/web/routes/integrations.$namespace.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IntegrationDetailPage } from "@executor-js/react/pages/integration-detail"; + +export const Route = createFileRoute("/integrations/$namespace")({ + component: () => { + const { namespace } = Route.useParams(); + return ; + }, +}); diff --git a/apps/host-selfhost/web/routes/integrations.add.$pluginKey.tsx b/apps/host-selfhost/web/routes/integrations.add.$pluginKey.tsx new file mode 100644 index 000000000..cdf2b8a8a --- /dev/null +++ b/apps/host-selfhost/web/routes/integrations.add.$pluginKey.tsx @@ -0,0 +1,22 @@ +import { Schema } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { AddIntegrationPage } from "@executor-js/react/pages/integration-add"; + +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + url: Schema.optional(Schema.String), + preset: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), + }), +); + +export const Route = createFileRoute("/integrations/add/$pluginKey")({ + validateSearch: SearchParams, + component: () => { + const { pluginKey } = Route.useParams(); + const { url, preset, namespace } = Route.useSearch(); + return ( + + ); + }, +}); diff --git a/apps/host-selfhost/web/routes/secrets.tsx b/apps/host-selfhost/web/routes/secrets.tsx index 190789172..10bd4a10b 100644 --- a/apps/host-selfhost/web/routes/secrets.tsx +++ b/apps/host-selfhost/web/routes/secrets.tsx @@ -1,23 +1,10 @@ -import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { SecretsPage } from "@executor-js/react/pages/secrets"; -// Query params from the agent-facing `secrets.create` static tool: it builds a -// URL like `/secrets?name=…&scope=…&secretId=…`; open the add modal pre-filled. -const SearchParams = Schema.toStandardSchemaV1( - Schema.Struct({ - name: Schema.optional(Schema.String), - secretId: Schema.optional(Schema.String), - provider: Schema.optional(Schema.String), - scope: Schema.optional(Schema.String), - }), -); - +// The Providers/Secrets page lets self-host users inspect their credential +// backends. Credential entry happens through the per-integration Add Account +// flow (`connections.createHandoff` → `/integrations/{slug}?addAccount=1`), +// not here, so this route takes no search params. export const Route = createFileRoute("/secrets")({ - validateSearch: SearchParams, - component: () => { - const { name, secretId, provider, scope } = Route.useSearch(); - const hasPrefill = name != null || secretId != null; - return ; - }, + component: () => , }); diff --git a/apps/host-selfhost/web/routes/sources.$namespace.tsx b/apps/host-selfhost/web/routes/sources.$namespace.tsx index 2bcdcce73..2df401d30 100644 --- a/apps/host-selfhost/web/routes/sources.$namespace.tsx +++ b/apps/host-selfhost/web/routes/sources.$namespace.tsx @@ -1,9 +1,9 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; +import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/sources/$namespace")({ - component: () => { - const { namespace } = Route.useParams(); - return ; + beforeLoad: ({ params }) => { + const { namespace } = params; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router redirects are modeled as thrown values + throw redirect({ to: "/integrations/$namespace", params: { namespace } }); }, }); diff --git a/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx b/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx index 48d58b32d..ec809b401 100644 --- a/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx +++ b/apps/host-selfhost/web/routes/sources.add.$pluginKey.tsx @@ -1,6 +1,5 @@ import { Schema } from "effect"; -import { createFileRoute } from "@tanstack/react-router"; -import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; +import { createFileRoute, redirect } from "@tanstack/react-router"; const SearchParams = Schema.toStandardSchemaV1( Schema.Struct({ @@ -12,9 +11,9 @@ const SearchParams = Schema.toStandardSchemaV1( export const Route = createFileRoute("/sources/add/$pluginKey")({ validateSearch: SearchParams, - component: () => { - const { pluginKey } = Route.useParams(); - const { url, preset, namespace } = Route.useSearch(); - return ; + beforeLoad: ({ params, search }) => { + const { pluginKey } = params; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TanStack Router redirects are modeled as thrown values + throw redirect({ to: "/integrations/add/$pluginKey", params: { pluginKey }, search }); }, }); diff --git a/apps/local/drizzle/0000_overconfident_sharon_carter.sql b/apps/local/drizzle-legacy-v1/0000_overconfident_sharon_carter.sql similarity index 100% rename from apps/local/drizzle/0000_overconfident_sharon_carter.sql rename to apps/local/drizzle-legacy-v1/0000_overconfident_sharon_carter.sql diff --git a/apps/local/drizzle/0001_sour_catseye.sql b/apps/local/drizzle-legacy-v1/0001_sour_catseye.sql similarity index 100% rename from apps/local/drizzle/0001_sour_catseye.sql rename to apps/local/drizzle-legacy-v1/0001_sour_catseye.sql diff --git a/apps/local/drizzle/0002_lively_sue_storm.sql b/apps/local/drizzle-legacy-v1/0002_lively_sue_storm.sql similarity index 100% rename from apps/local/drizzle/0002_lively_sue_storm.sql rename to apps/local/drizzle-legacy-v1/0002_lively_sue_storm.sql diff --git a/apps/local/drizzle/0003_little_silk_fever.sql b/apps/local/drizzle-legacy-v1/0003_little_silk_fever.sql similarity index 100% rename from apps/local/drizzle/0003_little_silk_fever.sql rename to apps/local/drizzle-legacy-v1/0003_little_silk_fever.sql diff --git a/apps/local/drizzle/0004_add_tool_policy.sql b/apps/local/drizzle-legacy-v1/0004_add_tool_policy.sql similarity index 100% rename from apps/local/drizzle/0004_add_tool_policy.sql rename to apps/local/drizzle-legacy-v1/0004_add_tool_policy.sql diff --git a/apps/local/drizzle/0005_repair_mcp_oauth_session.sql b/apps/local/drizzle-legacy-v1/0005_repair_mcp_oauth_session.sql similarity index 100% rename from apps/local/drizzle/0005_repair_mcp_oauth_session.sql rename to apps/local/drizzle-legacy-v1/0005_repair_mcp_oauth_session.sql diff --git a/apps/local/drizzle/0006_neat_terror.sql b/apps/local/drizzle-legacy-v1/0006_neat_terror.sql similarity index 100% rename from apps/local/drizzle/0006_neat_terror.sql rename to apps/local/drizzle-legacy-v1/0006_neat_terror.sql diff --git a/apps/local/drizzle/0007_normalize_plugin_secret_refs.sql b/apps/local/drizzle-legacy-v1/0007_normalize_plugin_secret_refs.sql similarity index 100% rename from apps/local/drizzle/0007_normalize_plugin_secret_refs.sql rename to apps/local/drizzle-legacy-v1/0007_normalize_plugin_secret_refs.sql diff --git a/apps/local/drizzle/0008_scoped_credentials_cutover.sql b/apps/local/drizzle-legacy-v1/0008_scoped_credentials_cutover.sql similarity index 100% rename from apps/local/drizzle/0008_scoped_credentials_cutover.sql rename to apps/local/drizzle-legacy-v1/0008_scoped_credentials_cutover.sql diff --git a/apps/local/drizzle/0009_repair_openapi_oauth_cutover_residue.sql b/apps/local/drizzle-legacy-v1/0009_repair_openapi_oauth_cutover_residue.sql similarity index 100% rename from apps/local/drizzle/0009_repair_openapi_oauth_cutover_residue.sql rename to apps/local/drizzle-legacy-v1/0009_repair_openapi_oauth_cutover_residue.sql diff --git a/apps/local/drizzle/0010_add_credential_binding_secret_scope.sql b/apps/local/drizzle-legacy-v1/0010_add_credential_binding_secret_scope.sql similarity index 100% rename from apps/local/drizzle/0010_add_credential_binding_secret_scope.sql rename to apps/local/drizzle-legacy-v1/0010_add_credential_binding_secret_scope.sql diff --git a/apps/local/drizzle/0011_plugin_storage_sources.sql b/apps/local/drizzle-legacy-v1/0011_plugin_storage_sources.sql similarity index 100% rename from apps/local/drizzle/0011_plugin_storage_sources.sql rename to apps/local/drizzle-legacy-v1/0011_plugin_storage_sources.sql diff --git a/apps/local/drizzle/0012_connection_identity_override.sql b/apps/local/drizzle-legacy-v1/0012_connection_identity_override.sql similarity index 100% rename from apps/local/drizzle/0012_connection_identity_override.sql rename to apps/local/drizzle-legacy-v1/0012_connection_identity_override.sql diff --git a/apps/local/drizzle-legacy-v1/meta/_journal.json b/apps/local/drizzle-legacy-v1/meta/_journal.json new file mode 100644 index 000000000..9167ab64d --- /dev/null +++ b/apps/local/drizzle-legacy-v1/meta/_journal.json @@ -0,0 +1,97 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1776367901699, + "tag": "0000_overconfident_sharon_carter", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1776680432025, + "tag": "0001_sour_catseye", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1776732062873, + "tag": "0002_lively_sue_storm", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1776976132767, + "tag": "0003_little_silk_fever", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1777800000000, + "tag": "0004_add_tool_policy", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1777850000000, + "tag": "0005_repair_mcp_oauth_session", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1777850000001, + "tag": "0006_neat_terror", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1778100000000, + "tag": "0007_normalize_plugin_secret_refs", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1778128200000, + "tag": "0008_scoped_credentials_cutover", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1778192434062, + "tag": "0009_repair_openapi_oauth_cutover_residue", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1778192434063, + "tag": "0010_add_credential_binding_secret_scope", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1779087600000, + "tag": "0011_plugin_storage_sources", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1779998400000, + "tag": "0012_connection_identity_override", + "breakpoints": true + } + ] +} diff --git a/apps/local/drizzle/0000_v2_baseline.sql b/apps/local/drizzle/0000_v2_baseline.sql new file mode 100644 index 000000000..bcdc657f4 --- /dev/null +++ b/apps/local/drizzle/0000_v2_baseline.sql @@ -0,0 +1,140 @@ +CREATE TABLE `blob` ( + `namespace` text NOT NULL, + `key` text NOT NULL, + `value` text NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `id` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `blob_id_uidx` ON `blob` (`id`);--> statement-breakpoint +CREATE TABLE `connection` ( + `integration` text NOT NULL, + `name` text NOT NULL, + `template` text NOT NULL, + `provider` text NOT NULL, + `item_ids` text NOT NULL, + `identity_label` text, + `oauth_client` text, + `oauth_client_owner` text, + `refresh_item_id` text, + `expires_at` blob, + `oauth_scope` text, + `provider_state` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `connection_uidx` ON `connection` (`tenant`,`owner`,`subject`,`integration`,`name`);--> statement-breakpoint +CREATE TABLE `definition` ( + `integration` text NOT NULL, + `connection` text NOT NULL, + `plugin_id` text NOT NULL, + `name` text NOT NULL, + `schema` text NOT NULL, + `created_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `definition_uidx` ON `definition` (`tenant`,`owner`,`subject`,`integration`,`connection`,`name`);--> statement-breakpoint +CREATE TABLE `integration` ( + `slug` text NOT NULL, + `plugin_id` text NOT NULL, + `description` text NOT NULL, + `config` text, + `can_remove` integer DEFAULT 1 NOT NULL, + `can_refresh` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `integration_uidx` ON `integration` (`tenant`,`slug`);--> statement-breakpoint +CREATE TABLE `oauth_client` ( + `slug` text NOT NULL, + `authorization_url` text NOT NULL, + `token_url` text NOT NULL, + `grant` text NOT NULL, + `client_id` text NOT NULL, + `client_secret_item_id` text, + `resource` text, + `created_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_client_uidx` ON `oauth_client` (`tenant`,`owner`,`subject`,`slug`);--> statement-breakpoint +CREATE TABLE `oauth_session` ( + `state` text NOT NULL, + `client_slug` text NOT NULL, + `integration` text NOT NULL, + `name` text NOT NULL, + `template` text NOT NULL, + `redirect_url` text NOT NULL, + `pkce_verifier` text, + `identity_label` text, + `payload` text NOT NULL, + `expires_at` blob NOT NULL, + `created_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `oauth_session_uidx` ON `oauth_session` (`tenant`,`state`);--> statement-breakpoint +CREATE TABLE `plugin_storage` ( + `plugin_id` text NOT NULL, + `collection` text NOT NULL, + `key` text NOT NULL, + `data` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `plugin_storage_uidx` ON `plugin_storage` (`tenant`,`owner`,`subject`,`plugin_id`,`collection`,`key`);--> statement-breakpoint +CREATE TABLE `tool` ( + `integration` text NOT NULL, + `connection` text NOT NULL, + `plugin_id` text NOT NULL, + `name` text NOT NULL, + `description` text NOT NULL, + `input_schema` text, + `output_schema` text, + `annotations` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tool_uidx` ON `tool` (`tenant`,`owner`,`subject`,`integration`,`connection`,`name`);--> statement-breakpoint +CREATE TABLE `tool_policy` ( + `id` text NOT NULL, + `pattern` text NOT NULL, + `action` text NOT NULL, + `position` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `row_id` text PRIMARY KEY NOT NULL, + `tenant` text NOT NULL, + `owner` text NOT NULL, + `subject` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tool_policy_uidx` ON `tool_policy` (`tenant`,`owner`,`subject`,`id`); diff --git a/apps/local/drizzle/0001_past_doctor_strange.sql b/apps/local/drizzle/0001_past_doctor_strange.sql new file mode 100644 index 000000000..d892bbf82 --- /dev/null +++ b/apps/local/drizzle/0001_past_doctor_strange.sql @@ -0,0 +1,2 @@ +ALTER TABLE `oauth_client` ADD `origin_kind` text;--> statement-breakpoint +ALTER TABLE `oauth_client` ADD `origin_integration` text; \ No newline at end of file diff --git a/apps/local/drizzle/meta/0000_snapshot.json b/apps/local/drizzle/meta/0000_snapshot.json index 97771ff07..dc9a2c6d4 100644 --- a/apps/local/drizzle/meta/0000_snapshot.json +++ b/apps/local/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "61fe33e0-7218-468e-8568-9a3f19e821ee", + "id": "61f37832-3e23-4991-85f5-3eedab6590cd", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "blob": { @@ -27,128 +27,119 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": ["id"], + "isUnique": true } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "definition": { - "name": "definition", + "connection": { + "name": "connection", "columns": { - "id": { - "name": "id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "template": { + "name": "template", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "plugin_id": { - "name": "plugin_id", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "item_ids": { + "name": "item_ids", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "schema": { - "name": "schema", + "identity_label": { + "name": "identity_label", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "oauth_client": { + "name": "oauth_client", + "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", + }, + "oauth_client_owner": { + "name": "oauth_client_owner", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "refresh_item_id": { + "name": "refresh_item_id", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": false, "autoincrement": false }, - "source_id": { - "name": "source_id", + "oauth_scope": { + "name": "oauth_scope", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "binding": { - "name": "binding", + "provider_state": { + "name": "provider_state", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, "created_at": { @@ -157,91 +148,74 @@ "primaryKey": false, "notNull": true, "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_oauth_session": { - "name": "google_discovery_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", + }, + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "session": { - "name": "session", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "expires_at": { - "name": "expires_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "google_discovery_oauth_session_scope_id_idx": { - "name": "google_discovery_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false + "connection_uidx": { + "name": "connection_uidx", + "columns": ["tenant", "owner", "subject", "integration", "name"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_oauth_session_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "google_discovery_source": { - "name": "google_discovery_source", + "definition": { + "name": "definition", "columns": { - "id": { - "name": "id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true, @@ -254,8 +228,8 @@ "notNull": true, "autoincrement": false }, - "config": { - "name": "config", + "schema": { + "name": "schema", "type": "text", "primaryKey": false, "notNull": true, @@ -268,57 +242,29 @@ "notNull": true, "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "binding": { - "name": "binding", + "subject": { + "name": "subject", "type": "text", "primaryKey": false, "notNull": true, @@ -326,229 +272,155 @@ } }, "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false + "definition_uidx": { + "name": "definition_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "graphql_source": { - "name": "graphql_source", + "integration": { + "name": "integration", "columns": { - "id": { - "name": "id", + "slug": { + "name": "slug", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "description": { + "name": "description", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "endpoint": { - "name": "endpoint", + "config": { + "name": "config", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "headers": { - "name": "headers", - "type": "text", + "can_remove": { + "name": "can_remove", + "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false + "autoincrement": false, + "default": 0 }, - "scope_id": { - "name": "scope_id", - "type": "text", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "binding": { - "name": "binding", + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "tenant": { + "name": "tenant", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false + "integration_uidx": { + "name": "integration_uidx", + "columns": ["tenant", "slug"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "mcp_oauth_session": { - "name": "mcp_oauth_session", + "oauth_client": { + "name": "oauth_client", "columns": { - "id": { - "name": "id", + "slug": { + "name": "slug", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "authorization_url": { + "name": "authorization_url", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "session": { - "name": "session", + "token_url": { + "name": "token_url", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", + "grant": { + "name": "grant", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "client_id": { + "name": "client_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "client_secret_item_id": { + "name": "client_secret_item_id", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "config": { - "name": "config", + "resource": { + "name": "resource", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, "created_at": { @@ -557,182 +429,151 @@ "primaryKey": false, "notNull": true, "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_oauth_session": { - "name": "openapi_oauth_session", - "columns": { - "id": { - "name": "id", + }, + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "session": { - "name": "session", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": ["tenant", "owner", "subject", "slug"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_oauth_session_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "openapi_operation": { - "name": "openapi_operation", + "oauth_session": { + "name": "oauth_session", "columns": { - "id": { - "name": "id", + "state": { + "name": "state", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "client_slug": { + "name": "client_slug", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "binding": { - "name": "binding", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", + }, + "template": { + "name": "template", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "redirect_url": { + "name": "redirect_url", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "pkce_verifier": { + "name": "pkce_verifier", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "spec": { - "name": "spec", + "identity_label": { + "name": "identity_label", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "base_url": { - "name": "base_url", + "payload": { + "name": "payload", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "headers": { - "name": "headers", + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "oauth2": { - "name": "oauth2", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "invocation_config": { - "name": "invocation_config", + "subject": { + "name": "subject", "type": "text", "primaryKey": false, "notNull": true, @@ -740,48 +581,43 @@ } }, "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": ["tenant", "state"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "secret": { - "name": "secret", + "plugin_storage": { + "name": "plugin_storage", "columns": { - "id": { - "name": "id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "collection": { + "name": "collection", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "key": { + "name": "key", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "provider": { - "name": "provider", + "data": { + "name": "data", "type": "text", "primaryKey": false, "notNull": true, @@ -793,42 +629,67 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "source": { - "name": "source", + "tool": { + "name": "tool", "columns": { - "id": { - "name": "id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "connection": { + "name": "connection", "type": "text", "primaryKey": false, "notNull": true, @@ -841,90 +702,98 @@ "notNull": true, "autoincrement": false }, - "kind": { - "name": "kind", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "description": { + "name": "description", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "url": { - "name": "url", + "input_schema": { + "name": "input_schema", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "can_remove": { - "name": "can_remove", + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": true + "autoincrement": false }, - "can_refresh": { - "name": "can_refresh", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": false + "autoincrement": false }, - "can_edit": { - "name": "can_edit", - "type": "integer", + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": false + "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "owner": { + "name": "owner", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false + "tool_uidx": { + "name": "tool_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "tool": { - "name": "tool", + "tool_policy": { + "name": "tool_policy", "columns": { "id": { "name": "id", @@ -933,94 +802,79 @@ "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "pattern": { + "name": "pattern", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "action": { + "name": "action", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "plugin_id": { - "name": "plugin_id", + "position": { + "name": "position", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", - "type": "text", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "description": { - "name": "description", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "input_schema": { - "name": "input_schema", + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, - "notNull": false, + "primaryKey": true, + "notNull": true, "autoincrement": false }, - "output_schema": { - "name": "output_schema", + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "owner": { + "name": "owner", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": ["tenant", "owner", "subject", "id"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} } diff --git a/apps/local/drizzle/meta/0001_snapshot.json b/apps/local/drizzle/meta/0001_snapshot.json index 99670d9e8..a4ea18aa9 100644 --- a/apps/local/drizzle/meta/0001_snapshot.json +++ b/apps/local/drizzle/meta/0001_snapshot.json @@ -1,8 +1,8 @@ { "version": "6", "dialect": "sqlite", - "id": "acff0d94-170c-40bb-9271-dc3a429ba0ce", - "prevId": "61fe33e0-7218-468e-8568-9a3f19e821ee", + "id": "54e1d8db-e750-4675-9195-fbeb14a0ea5e", + "prevId": "61f37832-3e23-4991-85f5-3eedab6590cd", "tables": { "blob": { "name": "blob", @@ -27,128 +27,119 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": ["id"], + "isUnique": true } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "definition": { - "name": "definition", + "connection": { + "name": "connection", "columns": { - "id": { - "name": "id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "template": { + "name": "template", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "plugin_id": { - "name": "plugin_id", + "provider": { + "name": "provider", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "item_ids": { + "name": "item_ids", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "schema": { - "name": "schema", + "identity_label": { + "name": "identity_label", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "oauth_client": { + "name": "oauth_client", + "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", + }, + "oauth_client_owner": { + "name": "oauth_client_owner", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "refresh_item_id": { + "name": "refresh_item_id", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": false, "autoincrement": false }, - "source_id": { - "name": "source_id", + "oauth_scope": { + "name": "oauth_scope", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "binding": { - "name": "binding", + "provider_state": { + "name": "provider_state", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, "created_at": { @@ -157,91 +148,74 @@ "primaryKey": false, "notNull": true, "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_oauth_session": { - "name": "google_discovery_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", + }, + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "session": { - "name": "session", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "expires_at": { - "name": "expires_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "google_discovery_oauth_session_scope_id_idx": { - "name": "google_discovery_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false + "connection_uidx": { + "name": "connection_uidx", + "columns": ["tenant", "owner", "subject", "integration", "name"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_oauth_session_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "google_discovery_source": { - "name": "google_discovery_source", + "definition": { + "name": "definition", "columns": { - "id": { - "name": "id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true, @@ -254,8 +228,8 @@ "notNull": true, "autoincrement": false }, - "config": { - "name": "config", + "schema": { + "name": "schema", "type": "text", "primaryKey": false, "notNull": true, @@ -268,57 +242,29 @@ "notNull": true, "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "binding": { - "name": "binding", + "subject": { + "name": "subject", "type": "text", "primaryKey": false, "notNull": true, @@ -326,229 +272,169 @@ } }, "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false + "definition_uidx": { + "name": "definition_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "graphql_source": { - "name": "graphql_source", + "integration": { + "name": "integration", "columns": { - "id": { - "name": "id", + "slug": { + "name": "slug", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "description": { + "name": "description", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "endpoint": { - "name": "endpoint", + "config": { + "name": "config", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "headers": { - "name": "headers", - "type": "text", + "can_remove": { + "name": "can_remove", + "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false + "autoincrement": false, + "default": 0 }, - "scope_id": { - "name": "scope_id", - "type": "text", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "binding": { - "name": "binding", + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "tenant": { + "name": "tenant", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false + "integration_uidx": { + "name": "integration_uidx", + "columns": ["tenant", "slug"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "mcp_oauth_session": { - "name": "mcp_oauth_session", + "oauth_client": { + "name": "oauth_client", "columns": { - "id": { - "name": "id", + "slug": { + "name": "slug", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "authorization_url": { + "name": "authorization_url", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "session": { - "name": "session", + "token_url": { + "name": "token_url", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "expires_at": { - "name": "expires_at", - "type": "integer", + "grant": { + "name": "grant", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "client_id": { + "name": "client_id", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", + }, + "client_secret_item_id": { + "name": "client_secret_item_id", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "resource": { + "name": "resource", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "name": { - "name": "name", + "origin_kind": { + "name": "origin_kind", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "config": { - "name": "config", + "origin_integration": { + "name": "origin_integration", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, "created_at": { @@ -557,189 +443,151 @@ "primaryKey": false, "notNull": true, "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_oauth_session": { - "name": "openapi_oauth_session", - "columns": { - "id": { - "name": "id", + }, + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, + "primaryKey": true, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "session": { - "name": "session", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": ["tenant", "owner", "subject", "slug"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_oauth_session_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "openapi_operation": { - "name": "openapi_operation", + "oauth_session": { + "name": "oauth_session", "columns": { - "id": { - "name": "id", + "state": { + "name": "state", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "client_slug": { + "name": "client_slug", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "binding": { - "name": "binding", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", + }, + "template": { + "name": "template", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "redirect_url": { + "name": "redirect_url", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "pkce_verifier": { + "name": "pkce_verifier", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "spec": { - "name": "spec", + "identity_label": { + "name": "identity_label", "type": "text", "primaryKey": false, - "notNull": true, + "notNull": false, "autoincrement": false }, - "source_url": { - "name": "source_url", + "payload": { + "name": "payload", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "base_url": { - "name": "base_url", - "type": "text", + "expires_at": { + "name": "expires_at", + "type": "blob", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "headers": { - "name": "headers", + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "oauth2": { - "name": "oauth2", + "owner": { + "name": "owner", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "invocation_config": { - "name": "invocation_config", + "subject": { + "name": "subject", "type": "text", "primaryKey": false, "notNull": true, @@ -747,48 +595,43 @@ } }, "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": ["tenant", "state"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "secret": { - "name": "secret", + "plugin_storage": { + "name": "plugin_storage", "columns": { - "id": { - "name": "id", + "plugin_id": { + "name": "plugin_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "collection": { + "name": "collection", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "key": { + "name": "key", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "provider": { - "name": "provider", + "data": { + "name": "data", "type": "text", "primaryKey": false, "notNull": true, @@ -800,42 +643,67 @@ "primaryKey": false, "notNull": true, "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false } }, "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "source": { - "name": "source", + "tool": { + "name": "tool", "columns": { - "id": { - "name": "id", + "integration": { + "name": "integration", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "connection": { + "name": "connection", "type": "text", "primaryKey": false, "notNull": true, @@ -848,90 +716,98 @@ "notNull": true, "autoincrement": false }, - "kind": { - "name": "kind", + "name": { + "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", + "description": { + "name": "description", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "url": { - "name": "url", + "input_schema": { + "name": "input_schema", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "can_remove": { - "name": "can_remove", + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": true + "autoincrement": false }, - "can_refresh": { - "name": "can_refresh", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": false + "autoincrement": false }, - "can_edit": { - "name": "can_edit", - "type": "integer", + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", "primaryKey": false, "notNull": true, - "autoincrement": false, - "default": false + "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "owner": { + "name": "owner", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false + "tool_uidx": { + "name": "tool_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, - "tool": { - "name": "tool", + "tool_policy": { + "name": "tool_policy", "columns": { "id": { "name": "id", @@ -940,94 +816,79 @@ "notNull": true, "autoincrement": false }, - "scope_id": { - "name": "scope_id", + "pattern": { + "name": "pattern", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "source_id": { - "name": "source_id", + "action": { + "name": "action", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "plugin_id": { - "name": "plugin_id", + "position": { + "name": "position", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "name": { - "name": "name", - "type": "text", + "created_at": { + "name": "created_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "description": { - "name": "description", - "type": "text", + "updated_at": { + "name": "updated_at", + "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "input_schema": { - "name": "input_schema", + "row_id": { + "name": "row_id", "type": "text", - "primaryKey": false, - "notNull": false, + "primaryKey": true, + "notNull": true, "autoincrement": false }, - "output_schema": { - "name": "output_schema", + "tenant": { + "name": "tenant", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, - "created_at": { - "name": "created_at", - "type": "integer", + "owner": { + "name": "owner", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updated_at": { - "name": "updated_at", - "type": "integer", + "subject": { + "name": "subject", + "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": ["tenant", "owner", "subject", "id"], + "isUnique": true } }, "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} } diff --git a/apps/local/drizzle/meta/0002_snapshot.json b/apps/local/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 40de70426..000000000 --- a/apps/local/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,1167 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "8778ceaf-ae8f-4fae-8484-4b0b2e3abe66", - "prevId": "acff0d94-170c-40bb-9271-dc3a429ba0ce", - "tables": { - "blob": { - "name": "blob", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "connection": { - "name": "connection", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_state": { - "name": "provider_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": ["provider"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "connection_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "definition": { - "name": "definition", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema": { - "name": "schema", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_oauth_session": { - "name": "google_discovery_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_oauth_session_scope_id_idx": { - "name": "google_discovery_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source": { - "name": "google_discovery_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source": { - "name": "graphql_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_oauth_session": { - "name": "mcp_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_oauth_session": { - "name": "openapi_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_operation": { - "name": "openapi_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth2": { - "name": "oauth2", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "secret": { - "name": "secret", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": ["owned_by_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "source": { - "name": "source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "can_remove": { - "name": "can_remove", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool": { - "name": "tool", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_schema": { - "name": "input_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output_schema": { - "name": "output_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/local/drizzle/meta/0003_snapshot.json b/apps/local/drizzle/meta/0003_snapshot.json deleted file mode 100644 index ce8f6aaa8..000000000 --- a/apps/local/drizzle/meta/0003_snapshot.json +++ /dev/null @@ -1,1247 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "b20a0eff-12a3-4709-9389-4353e5191535", - "prevId": "8778ceaf-ae8f-4fae-8484-4b0b2e3abe66", - "tables": { - "connection": { - "name": "connection", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_state": { - "name": "provider_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": ["provider"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "connection_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "definition": { - "name": "definition", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema": { - "name": "schema", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_oauth_session": { - "name": "google_discovery_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_oauth_session_scope_id_idx": { - "name": "google_discovery_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source": { - "name": "google_discovery_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source": { - "name": "graphql_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_oauth_session": { - "name": "mcp_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_oauth_session": { - "name": "openapi_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_operation": { - "name": "openapi_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth2": { - "name": "oauth2", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_binding": { - "name": "openapi_source_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": ["source_scope_id"], - "isUnique": false - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": ["target_scope_id"], - "isUnique": false - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": ["slot"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "secret": { - "name": "secret", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": ["owned_by_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "source": { - "name": "source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "can_remove": { - "name": "can_remove", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool": { - "name": "tool", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_schema": { - "name": "input_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output_schema": { - "name": "output_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "blob": { - "name": "blob", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/local/drizzle/meta/0004_snapshot.json b/apps/local/drizzle/meta/0004_snapshot.json deleted file mode 100644 index a443a5d53..000000000 --- a/apps/local/drizzle/meta/0004_snapshot.json +++ /dev/null @@ -1,1317 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "d19b4bf7-780a-40ef-b6e9-a58a53ac25c5", - "prevId": "b20a0eff-12a3-4709-9389-4353e5191535", - "tables": { - "connection": { - "name": "connection", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_state": { - "name": "provider_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": ["provider"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "connection_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "definition": { - "name": "definition", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema": { - "name": "schema", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_oauth_session": { - "name": "google_discovery_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_oauth_session_scope_id_idx": { - "name": "google_discovery_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source": { - "name": "google_discovery_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source": { - "name": "graphql_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_oauth_session": { - "name": "mcp_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_oauth_session": { - "name": "openapi_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_operation": { - "name": "openapi_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth2": { - "name": "oauth2", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_binding": { - "name": "openapi_source_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": ["source_scope_id"], - "isUnique": false - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": ["target_scope_id"], - "isUnique": false - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": ["slot"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "secret": { - "name": "secret", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": ["owned_by_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "source": { - "name": "source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "can_remove": { - "name": "can_remove", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool": { - "name": "tool", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_schema": { - "name": "input_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output_schema": { - "name": "output_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool_policy": { - "name": "tool_policy", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "position": { - "name": "position", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": ["scope_id", "position"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_policy_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "blob": { - "name": "blob", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/local/drizzle/meta/0005_snapshot.json b/apps/local/drizzle/meta/0005_snapshot.json deleted file mode 100644 index 8d02d6104..000000000 --- a/apps/local/drizzle/meta/0005_snapshot.json +++ /dev/null @@ -1,1317 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "c7307ba7-0ed4-46da-b40f-637aa4fd6677", - "prevId": "d19b4bf7-780a-40ef-b6e9-a58a53ac25c5", - "tables": { - "connection": { - "name": "connection", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_state": { - "name": "provider_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": ["provider"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "connection_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "definition": { - "name": "definition", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema": { - "name": "schema", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_oauth_session": { - "name": "google_discovery_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_oauth_session_scope_id_idx": { - "name": "google_discovery_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source": { - "name": "google_discovery_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source": { - "name": "graphql_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_oauth_session": { - "name": "mcp_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_oauth_session_scope_id_idx": { - "name": "mcp_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_oauth_session": { - "name": "openapi_oauth_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "session": { - "name": "session", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_oauth_session_scope_id_idx": { - "name": "openapi_oauth_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_oauth_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_oauth_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_operation": { - "name": "openapi_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth2": { - "name": "oauth2", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_binding": { - "name": "openapi_source_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": ["source_scope_id"], - "isUnique": false - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": ["target_scope_id"], - "isUnique": false - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": ["slot"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "secret": { - "name": "secret", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": ["owned_by_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "source": { - "name": "source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "can_remove": { - "name": "can_remove", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool": { - "name": "tool", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_schema": { - "name": "input_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output_schema": { - "name": "output_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool_policy": { - "name": "tool_policy", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "position": { - "name": "position", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": ["scope_id", "position"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_policy_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "blob": { - "name": "blob", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/local/drizzle/meta/0006_snapshot.json b/apps/local/drizzle/meta/0006_snapshot.json deleted file mode 100644 index a37401377..000000000 --- a/apps/local/drizzle/meta/0006_snapshot.json +++ /dev/null @@ -1,1285 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "7a331df8-ce69-4d97-b6ce-6bf3aff98b56", - "prevId": "c7307ba7-0ed4-46da-b40f-637aa4fd6677", - "tables": { - "blob": { - "name": "blob", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "connection": { - "name": "connection", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_state": { - "name": "provider_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": ["provider"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "connection_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "definition": { - "name": "definition", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema": { - "name": "schema", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source": { - "name": "google_discovery_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source": { - "name": "graphql_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "query_params": { - "name": "query_params", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth": { - "name": "auth", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "oauth2_session": { - "name": "oauth2_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "strategy": { - "name": "strategy", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "token_scope": { - "name": "token_scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload": { - "name": "payload", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "oauth2_session_scope_id_idx": { - "name": "oauth2_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "oauth2_session_plugin_id_idx": { - "name": "oauth2_session_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - }, - "oauth2_session_connection_id_idx": { - "name": "oauth2_session_connection_id_idx", - "columns": ["connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "oauth2_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "oauth2_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_operation": { - "name": "openapi_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "query_params": { - "name": "query_params", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth2": { - "name": "oauth2", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "invocation_config": { - "name": "invocation_config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_binding": { - "name": "openapi_source_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": ["source_scope_id"], - "isUnique": false - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": ["target_scope_id"], - "isUnique": false - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": ["slot"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "secret": { - "name": "secret", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": ["owned_by_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "source": { - "name": "source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "can_remove": { - "name": "can_remove", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool": { - "name": "tool", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_schema": { - "name": "input_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output_schema": { - "name": "output_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool_policy": { - "name": "tool_policy", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "position": { - "name": "position", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": ["scope_id", "position"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_policy_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/local/drizzle/meta/0007_snapshot.json b/apps/local/drizzle/meta/0007_snapshot.json deleted file mode 100644 index 2ca41e999..000000000 --- a/apps/local/drizzle/meta/0007_snapshot.json +++ /dev/null @@ -1,2206 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "44444444-5555-6666-7777-888888888888", - "prevId": "7a331df8-ce69-4d97-b6ce-6bf3aff98b56", - "tables": { - "blob": { - "name": "blob", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "connection": { - "name": "connection", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_state": { - "name": "provider_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": ["provider"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "connection_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "definition": { - "name": "definition", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema": { - "name": "schema", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source": { - "name": "google_discovery_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "auth_connection_id": { - "name": "auth_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_id_secret_id": { - "name": "auth_client_id_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_secret_secret_id": { - "name": "auth_client_secret_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_scopes": { - "name": "auth_scopes", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_source_auth_connection_id_idx": { - "name": "google_discovery_source_auth_connection_id_idx", - "columns": ["auth_connection_id"], - "isUnique": false - }, - "google_discovery_source_auth_client_id_secret_id_idx": { - "name": "google_discovery_source_auth_client_id_secret_id_idx", - "columns": ["auth_client_id_secret_id"], - "isUnique": false - }, - "google_discovery_source_auth_client_secret_secret_id_idx": { - "name": "google_discovery_source_auth_client_secret_secret_id_idx", - "columns": ["auth_client_secret_secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source": { - "name": "graphql_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "auth_connection_id": { - "name": "auth_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_source_auth_connection_id_idx": { - "name": "graphql_source_auth_connection_id_idx", - "columns": ["auth_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "auth_header_name": { - "name": "auth_header_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_secret_id": { - "name": "auth_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_secret_prefix": { - "name": "auth_secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_connection_id": { - "name": "auth_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_id_secret_id": { - "name": "auth_client_id_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_secret_secret_id": { - "name": "auth_client_secret_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_source_auth_secret_id_idx": { - "name": "mcp_source_auth_secret_id_idx", - "columns": ["auth_secret_id"], - "isUnique": false - }, - "mcp_source_auth_connection_id_idx": { - "name": "mcp_source_auth_connection_id_idx", - "columns": ["auth_connection_id"], - "isUnique": false - }, - "mcp_source_auth_client_id_secret_id_idx": { - "name": "mcp_source_auth_client_id_secret_id_idx", - "columns": ["auth_client_id_secret_id"], - "isUnique": false - }, - "mcp_source_auth_client_secret_secret_id_idx": { - "name": "mcp_source_auth_client_secret_secret_id_idx", - "columns": ["auth_client_secret_secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "oauth2_session": { - "name": "oauth2_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "strategy": { - "name": "strategy", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "token_scope": { - "name": "token_scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload": { - "name": "payload", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "oauth2_session_scope_id_idx": { - "name": "oauth2_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "oauth2_session_plugin_id_idx": { - "name": "oauth2_session_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - }, - "oauth2_session_connection_id_idx": { - "name": "oauth2_session_connection_id_idx", - "columns": ["connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "oauth2_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "oauth2_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_operation": { - "name": "openapi_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "headers": { - "name": "headers", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth2": { - "name": "oauth2", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_binding": { - "name": "openapi_source_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "target_scope_id": { - "name": "target_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "slot": { - "name": "slot", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'text'" - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_binding_source_id_idx": { - "name": "openapi_source_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_binding_source_scope_id_idx": { - "name": "openapi_source_binding_source_scope_id_idx", - "columns": ["source_scope_id"], - "isUnique": false - }, - "openapi_source_binding_target_scope_id_idx": { - "name": "openapi_source_binding_target_scope_id_idx", - "columns": ["target_scope_id"], - "isUnique": false - }, - "openapi_source_binding_slot_idx": { - "name": "openapi_source_binding_slot_idx", - "columns": ["slot"], - "isUnique": false - }, - "openapi_source_binding_secret_id_idx": { - "name": "openapi_source_binding_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - }, - "openapi_source_binding_connection_id_idx": { - "name": "openapi_source_binding_connection_id_idx", - "columns": ["connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "secret": { - "name": "secret", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": ["owned_by_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "source": { - "name": "source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "can_remove": { - "name": "can_remove", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool": { - "name": "tool", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_schema": { - "name": "input_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output_schema": { - "name": "output_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool_policy": { - "name": "tool_policy", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "position": { - "name": "position", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": ["scope_id", "position"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_policy_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source_header": { - "name": "graphql_source_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_header_scope_id_idx": { - "name": "graphql_source_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_source_header_source_id_idx": { - "name": "graphql_source_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "graphql_source_header_secret_id_idx": { - "name": "graphql_source_header_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source_query_param": { - "name": "graphql_source_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_query_param_scope_id_idx": { - "name": "graphql_source_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_source_query_param_source_id_idx": { - "name": "graphql_source_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "graphql_source_query_param_secret_id_idx": { - "name": "graphql_source_query_param_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_query_param": { - "name": "openapi_source_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_query_param_scope_id_idx": { - "name": "openapi_source_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_source_query_param_source_id_idx": { - "name": "openapi_source_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_query_param_secret_id_idx": { - "name": "openapi_source_query_param_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_spec_fetch_header": { - "name": "openapi_source_spec_fetch_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_spec_fetch_header_scope_id_idx": { - "name": "openapi_source_spec_fetch_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_source_spec_fetch_header_source_id_idx": { - "name": "openapi_source_spec_fetch_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_spec_fetch_header_secret_id_idx": { - "name": "openapi_source_spec_fetch_header_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_spec_fetch_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_spec_fetch_query_param": { - "name": "openapi_source_spec_fetch_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_spec_fetch_query_param_scope_id_idx": { - "name": "openapi_source_spec_fetch_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_source_spec_fetch_query_param_source_id_idx": { - "name": "openapi_source_spec_fetch_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "openapi_source_spec_fetch_query_param_secret_id_idx": { - "name": "openapi_source_spec_fetch_query_param_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_spec_fetch_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source_header": { - "name": "mcp_source_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_header_scope_id_idx": { - "name": "mcp_source_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_source_header_source_id_idx": { - "name": "mcp_source_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "mcp_source_header_secret_id_idx": { - "name": "mcp_source_header_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source_query_param": { - "name": "mcp_source_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_query_param_scope_id_idx": { - "name": "mcp_source_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_source_query_param_source_id_idx": { - "name": "mcp_source_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "mcp_source_query_param_secret_id_idx": { - "name": "mcp_source_query_param_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source_credential_header": { - "name": "google_discovery_source_credential_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_credential_header_scope_id_idx": { - "name": "google_discovery_source_credential_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_source_credential_header_source_id_idx": { - "name": "google_discovery_source_credential_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "google_discovery_source_credential_header_secret_id_idx": { - "name": "google_discovery_source_credential_header_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_credential_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_credential_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source_credential_query_param": { - "name": "google_discovery_source_credential_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_credential_query_param_scope_id_idx": { - "name": "google_discovery_source_credential_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_source_credential_query_param_source_id_idx": { - "name": "google_discovery_source_credential_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "google_discovery_source_credential_query_param_secret_id_idx": { - "name": "google_discovery_source_credential_query_param_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_credential_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_credential_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/local/drizzle/meta/0008_snapshot.json b/apps/local/drizzle/meta/0008_snapshot.json deleted file mode 100644 index 41729a3cb..000000000 --- a/apps/local/drizzle/meta/0008_snapshot.json +++ /dev/null @@ -1,2242 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "a3f01483-cc06-4c7e-9ef5-a15be79fd2c2", - "prevId": "44444444-5555-6666-7777-888888888888", - "tables": { - "blob": { - "name": "blob", - "columns": { - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "blob_namespace_key_pk": { - "columns": ["namespace", "key"], - "name": "blob_namespace_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "connection": { - "name": "connection", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "identity_label": { - "name": "identity_label", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_secret_id": { - "name": "access_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "refresh_token_secret_id": { - "name": "refresh_token_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "provider_state": { - "name": "provider_state", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "connection_scope_id_idx": { - "name": "connection_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "connection_provider_idx": { - "name": "connection_provider_idx", - "columns": ["provider"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "connection_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "connection_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "credential_binding": { - "name": "credential_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_scope_id": { - "name": "source_scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "credential_binding_scope_id_idx": { - "name": "credential_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "credential_binding_plugin_id_idx": { - "name": "credential_binding_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - }, - "credential_binding_source_id_idx": { - "name": "credential_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "credential_binding_source_scope_id_idx": { - "name": "credential_binding_source_scope_id_idx", - "columns": ["source_scope_id"], - "isUnique": false - }, - "credential_binding_slot_key_idx": { - "name": "credential_binding_slot_key_idx", - "columns": ["slot_key"], - "isUnique": false - }, - "credential_binding_kind_idx": { - "name": "credential_binding_kind_idx", - "columns": ["kind"], - "isUnique": false - }, - "credential_binding_secret_id_idx": { - "name": "credential_binding_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - }, - "credential_binding_connection_id_idx": { - "name": "credential_binding_connection_id_idx", - "columns": ["connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "credential_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "credential_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "definition": { - "name": "definition", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "schema": { - "name": "schema", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "definition_scope_id_idx": { - "name": "definition_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "definition_source_id_idx": { - "name": "definition_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "definition_plugin_id_idx": { - "name": "definition_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "definition_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "definition_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_binding": { - "name": "google_discovery_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_binding_scope_id_idx": { - "name": "google_discovery_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_binding_source_id_idx": { - "name": "google_discovery_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source": { - "name": "google_discovery_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "auth_connection_id": { - "name": "auth_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_id_secret_id": { - "name": "auth_client_id_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_secret_secret_id": { - "name": "auth_client_secret_secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_scopes": { - "name": "auth_scopes", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_scope_id_idx": { - "name": "google_discovery_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_source_auth_connection_id_idx": { - "name": "google_discovery_source_auth_connection_id_idx", - "columns": ["auth_connection_id"], - "isUnique": false - }, - "google_discovery_source_auth_client_id_secret_id_idx": { - "name": "google_discovery_source_auth_client_id_secret_id_idx", - "columns": ["auth_client_id_secret_id"], - "isUnique": false - }, - "google_discovery_source_auth_client_secret_secret_id_idx": { - "name": "google_discovery_source_auth_client_secret_secret_id_idx", - "columns": ["auth_client_secret_secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source_credential_header": { - "name": "google_discovery_source_credential_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_credential_header_scope_id_idx": { - "name": "google_discovery_source_credential_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_source_credential_header_source_id_idx": { - "name": "google_discovery_source_credential_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "google_discovery_source_credential_header_secret_id_idx": { - "name": "google_discovery_source_credential_header_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_credential_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_credential_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "google_discovery_source_credential_query_param": { - "name": "google_discovery_source_credential_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_id": { - "name": "secret_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "secret_prefix": { - "name": "secret_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "google_discovery_source_credential_query_param_scope_id_idx": { - "name": "google_discovery_source_credential_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "google_discovery_source_credential_query_param_source_id_idx": { - "name": "google_discovery_source_credential_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "google_discovery_source_credential_query_param_secret_id_idx": { - "name": "google_discovery_source_credential_query_param_secret_id_idx", - "columns": ["secret_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "google_discovery_source_credential_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "google_discovery_source_credential_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_operation": { - "name": "graphql_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "graphql_operation_scope_id_idx": { - "name": "graphql_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_operation_source_id_idx": { - "name": "graphql_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source": { - "name": "graphql_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "endpoint": { - "name": "endpoint", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "auth_connection_slot": { - "name": "auth_connection_slot", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_scope_id_idx": { - "name": "graphql_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source_header": { - "name": "graphql_source_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_header_scope_id_idx": { - "name": "graphql_source_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_source_header_source_id_idx": { - "name": "graphql_source_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "graphql_source_query_param": { - "name": "graphql_source_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "graphql_source_query_param_scope_id_idx": { - "name": "graphql_source_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "graphql_source_query_param_source_id_idx": { - "name": "graphql_source_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "graphql_source_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "graphql_source_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_binding": { - "name": "mcp_binding", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_binding_scope_id_idx": { - "name": "mcp_binding_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_binding_source_id_idx": { - "name": "mcp_binding_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_binding_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_binding_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source": { - "name": "mcp_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "config": { - "name": "config", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "auth_kind": { - "name": "auth_kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'none'" - }, - "auth_header_name": { - "name": "auth_header_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_header_slot": { - "name": "auth_header_slot", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_header_prefix": { - "name": "auth_header_prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_connection_slot": { - "name": "auth_connection_slot", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_id_slot": { - "name": "auth_client_id_slot", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "auth_client_secret_slot": { - "name": "auth_client_secret_slot", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_scope_id_idx": { - "name": "mcp_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source_header": { - "name": "mcp_source_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_header_scope_id_idx": { - "name": "mcp_source_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_source_header_source_id_idx": { - "name": "mcp_source_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "mcp_source_query_param": { - "name": "mcp_source_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "mcp_source_query_param_scope_id_idx": { - "name": "mcp_source_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "mcp_source_query_param_source_id_idx": { - "name": "mcp_source_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "mcp_source_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "mcp_source_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "oauth2_session": { - "name": "oauth2_session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "strategy": { - "name": "strategy", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "connection_id": { - "name": "connection_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "token_scope": { - "name": "token_scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "redirect_url": { - "name": "redirect_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload": { - "name": "payload", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "oauth2_session_scope_id_idx": { - "name": "oauth2_session_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "oauth2_session_plugin_id_idx": { - "name": "oauth2_session_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - }, - "oauth2_session_connection_id_idx": { - "name": "oauth2_session_connection_id_idx", - "columns": ["connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "oauth2_session_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "oauth2_session_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_operation": { - "name": "openapi_operation", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "binding": { - "name": "binding", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "openapi_operation_scope_id_idx": { - "name": "openapi_operation_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_operation_source_id_idx": { - "name": "openapi_operation_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_operation_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_operation_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source": { - "name": "openapi_source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "spec": { - "name": "spec", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_url": { - "name": "source_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "base_url": { - "name": "base_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "oauth2": { - "name": "oauth2", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_scope_id_idx": { - "name": "openapi_source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_header": { - "name": "openapi_source_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_header_scope_id_idx": { - "name": "openapi_source_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_source_header_source_id_idx": { - "name": "openapi_source_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_query_param": { - "name": "openapi_source_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_query_param_scope_id_idx": { - "name": "openapi_source_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_source_query_param_source_id_idx": { - "name": "openapi_source_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_spec_fetch_header": { - "name": "openapi_source_spec_fetch_header", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_spec_fetch_header_scope_id_idx": { - "name": "openapi_source_spec_fetch_header_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_source_spec_fetch_header_source_id_idx": { - "name": "openapi_source_spec_fetch_header_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_header_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_spec_fetch_header_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "openapi_source_spec_fetch_query_param": { - "name": "openapi_source_spec_fetch_query_param", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "text_value": { - "name": "text_value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "slot_key": { - "name": "slot_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prefix": { - "name": "prefix", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "openapi_source_spec_fetch_query_param_scope_id_idx": { - "name": "openapi_source_spec_fetch_query_param_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "openapi_source_spec_fetch_query_param_source_id_idx": { - "name": "openapi_source_spec_fetch_query_param_source_id_idx", - "columns": ["source_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "openapi_source_spec_fetch_query_param_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "openapi_source_spec_fetch_query_param_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "secret": { - "name": "secret", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "owned_by_connection_id": { - "name": "owned_by_connection_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "secret_scope_id_idx": { - "name": "secret_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "secret_provider_idx": { - "name": "secret_provider_idx", - "columns": ["provider"], - "isUnique": false - }, - "secret_owned_by_connection_id_idx": { - "name": "secret_owned_by_connection_id_idx", - "columns": ["owned_by_connection_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "secret_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "secret_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "source": { - "name": "source", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "can_remove": { - "name": "can_remove", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": true - }, - "can_refresh": { - "name": "can_refresh", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "can_edit": { - "name": "can_edit", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "source_scope_id_idx": { - "name": "source_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "source_plugin_id_idx": { - "name": "source_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "source_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "source_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool": { - "name": "tool", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "plugin_id": { - "name": "plugin_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "input_schema": { - "name": "input_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "output_schema": { - "name": "output_schema", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_scope_id_idx": { - "name": "tool_scope_id_idx", - "columns": ["scope_id"], - "isUnique": false - }, - "tool_source_id_idx": { - "name": "tool_source_id_idx", - "columns": ["source_id"], - "isUnique": false - }, - "tool_plugin_id_idx": { - "name": "tool_plugin_id_idx", - "columns": ["plugin_id"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tool_policy": { - "name": "tool_policy", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "scope_id": { - "name": "scope_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pattern": { - "name": "pattern", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "position": { - "name": "position", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "tool_policy_scope_id_position_idx": { - "name": "tool_policy_scope_id_position_idx", - "columns": ["scope_id", "position"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "tool_policy_scope_id_id_pk": { - "columns": ["scope_id", "id"], - "name": "tool_policy_scope_id_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index 9167ab64d..ca3d68845 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -5,92 +5,15 @@ { "idx": 0, "version": "6", - "when": 1776367901699, - "tag": "0000_overconfident_sharon_carter", + "when": 1780729924132, + "tag": "0000_v2_baseline", "breakpoints": true }, { "idx": 1, "version": "6", - "when": 1776680432025, - "tag": "0001_sour_catseye", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1776732062873, - "tag": "0002_lively_sue_storm", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1776976132767, - "tag": "0003_little_silk_fever", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1777800000000, - "tag": "0004_add_tool_policy", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1777850000000, - "tag": "0005_repair_mcp_oauth_session", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1777850000001, - "tag": "0006_neat_terror", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1778100000000, - "tag": "0007_normalize_plugin_secret_refs", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1778128200000, - "tag": "0008_scoped_credentials_cutover", - "breakpoints": true - }, - { - "idx": 9, - "version": "6", - "when": 1778192434062, - "tag": "0009_repair_openapi_oauth_cutover_residue", - "breakpoints": true - }, - { - "idx": 10, - "version": "6", - "when": 1778192434063, - "tag": "0010_add_credential_binding_secret_scope", - "breakpoints": true - }, - { - "idx": 11, - "version": "6", - "when": 1779087600000, - "tag": "0011_plugin_storage_sources", - "breakpoints": true - }, - { - "idx": 12, - "version": "6", - "when": 1779998400000, - "tag": "0012_connection_identity_override", + "when": 1780991784172, + "tag": "0001_past_doctor_strange", "breakpoints": true } ] diff --git a/apps/local/src/auth-tool-failures.test.ts b/apps/local/src/auth-tool-failures.test.ts index 90ab38d87..59ae264f0 100644 --- a/apps/local/src/auth-tool-failures.test.ts +++ b/apps/local/src/auth-tool-failures.test.ts @@ -10,6 +10,11 @@ // The assertion is intentionally on the final execution payload, not the // plugin facade, so reviewers can see that model-visible tool results carry // auth guidance instead of an opaque internal tool error. +// +// v2: a connection IS the credential. addSpec registers the integration with an +// apiKey auth template; a connection is then created whose value cannot resolve +// (a `from` reference to a missing provider item). Invoking one of that +// connection's tools surfaces `connection_value_missing` to the model. // --------------------------------------------------------------------------- import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; @@ -45,7 +50,17 @@ import { } from "@executor-js/plugin-openapi/api"; import { makeOpenApiHttpApiTestAddSpecPayload } from "@executor-js/plugin-openapi/testing"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { Scope, ScopeId, createExecutor } from "@executor-js/sdk"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + Subject, + Tenant, + createExecutor, +} from "@executor-js/sdk"; +import { memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { ErrorCaptureLive } from "./observability"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; @@ -64,17 +79,22 @@ type TestApiShape = ? HttpApiClient.Client : never; +const API_KEY_TEMPLATE = "apiKey"; + interface Harness { readonly fetch: typeof globalThis.fetch; - readonly scopeId: ScopeId; + readonly addConnection: (input: { + readonly integration: string; + readonly connection: string; + }) => Promise; readonly dispose: () => Promise; } const startHarness = async (tmpDir: string): Promise => { - const scopeId = ScopeId.make(`test-${randomBytes(4).toString("hex")}`); const plugins = [ openApiPlugin({ httpClientLayer: FetchHttpClient.layer }), fileSecretsPlugin({ directory: tmpDir }), + memoryCredentialsPlugin(), ] as const; const sqlite = await createSqliteFumaDb({ tables: collectTables(), @@ -84,13 +104,8 @@ const startHarness = async (tmpDir: string): Promise => { const executor = await Effect.runPromise( createExecutor({ - scopes: [ - Scope.make({ - id: scopeId, - name: "test", - createdAt: new Date(), - }), - ], + tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), + subject: Subject.make("local"), db: sqlite.db, plugins, onElicitation: "accept-all", @@ -125,7 +140,24 @@ const startHarness = async (tmpDir: string): Promise => { webHandler( input instanceof Request ? input : new Request(input, init), )) as typeof globalThis.fetch, - scopeId, + // Create an org connection whose value cannot resolve: a `from` reference + // to a memory-provider item that was never stored resolves to `null`, so + // tool invocation surfaces `connection_value_missing`. + addConnection: (input) => + Effect.runPromise( + executor.connections + .create({ + owner: "org", + name: ConnectionName.make(input.connection), + integration: IntegrationSlug.make(input.integration), + template: AuthTemplateSlug.make(API_KEY_TEMPLATE), + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make(`${input.integration}-missing`), + }, + }) + .pipe(Effect.asVoid), + ), dispose: async () => { await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => disposeHandler()))); await Effect.runPromise( @@ -138,7 +170,9 @@ const startHarness = async (tmpDir: string): Promise => { const run = (body: (client: TestApiShape) => Effect.Effect): Effect.Effect => Effect.gen(function* () { - const client = yield* HttpApiClient.make(TestApi, { baseUrl: TEST_BASE_URL }); + const client = yield* HttpApiClient.make(TestApi, { + baseUrl: TEST_BASE_URL, + }); return yield* body(client); }).pipe( Effect.provide( @@ -162,12 +196,12 @@ const expectModelVisibleAuthFailure = (execution: ExecuteResult) => { result: { ok: false, error: { - code: "credential_binding_missing", + code: "connection_value_missing", details: { category: "authentication", recovery: { - createSecretTool: "executor.coreTools.secrets.create", - secretsUrl: "https://executor.sh/secrets", + createConnectionTool: "executor.coreTools.connections.createHandoff", + listConnectionsTool: "executor.coreTools.connections.list", }, }, }, @@ -189,29 +223,37 @@ afterAll(async () => { }); describe("local auth tool failures", () => { - it.effect("local propagates missing credential binding as model-visible auth failure", () => + it.effect("local propagates missing credential value as model-visible auth failure", () => Effect.gen(function* () { - const namespace = `auth_${randomBytes(4).toString("hex")}`; + const integration = `auth_${randomBytes(4).toString("hex")}`; + const connection = "main"; yield* run((client) => client.openapi.addSpec({ - params: { scopeId: harness.scopeId }, payload: { ...makeOpenApiHttpApiTestAddSpecPayload(MissingAuthSourceApi, { - namespace, - headers: { - Authorization: { kind: "secret", prefix: "Bearer " }, - }, + slug: integration, + authenticationTemplate: [ + { + slug: AuthTemplateSlug.make(API_KEY_TEMPLATE), + type: "apiKey" as const, + headers: { + Authorization: ["Bearer ", { type: "variable" as const, name: "token" }], + }, + }, + ], }), baseUrl: "https://api.example.test", }, }), ); + yield* Effect.promise(() => harness.addConnection({ integration, connection })); + const execution = yield* run((client) => client.executions.execute({ payload: { code: [ - `const result = await tools.${namespace}.default.ping({});`, + `const result = await tools.${integration}.org.${connection}.default.ping({});`, "return result;", ].join("\n"), }, diff --git a/apps/local/src/db/db-upgrade.test.ts b/apps/local/src/db/db-upgrade.test.ts deleted file mode 100644 index 8eb4c5b2b..000000000 --- a/apps/local/src/db/db-upgrade.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -// Upgrade path for local DBs written by pre-scope executor versions. -// -// These helpers still run before the one-shot FumaDB import. They detect -// SQLite files whose core tables predate `scope_id`, move the file set aside, -// and preserve legacy secret routing rows for the fresh scoped database. - -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { openTestDb, runMigrations } from "../testing/libsql-test-db"; -import { - importLegacySecrets, - isPreScopeSchema, - moveAsidePreScopeDb, - readLegacySecrets, -} from "./db-upgrade"; - -const PRE_SCOPE_SCHEMA = ` - CREATE TABLE source ( - id TEXT PRIMARY KEY NOT NULL, - plugin_id TEXT NOT NULL, - kind TEXT NOT NULL, - name TEXT NOT NULL, - can_remove INTEGER DEFAULT 1 NOT NULL, - can_refresh INTEGER DEFAULT 0 NOT NULL, - can_edit INTEGER DEFAULT 0 NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE tool ( - id TEXT PRIMARY KEY NOT NULL, - source_id TEXT NOT NULL, - plugin_id TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE secret ( - id TEXT PRIMARY KEY NOT NULL, - name TEXT NOT NULL, - provider TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - CREATE TABLE blob ( - namespace TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - PRIMARY KEY (namespace, key) - ); -`; - -const SCOPED_SCHEMA = ` - CREATE TABLE source ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - plugin_id TEXT NOT NULL, - kind TEXT NOT NULL, - name TEXT NOT NULL, - can_remove INTEGER DEFAULT 1 NOT NULL, - can_refresh INTEGER DEFAULT 0 NOT NULL, - can_edit INTEGER DEFAULT 0 NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); -`; - -const seed = async (path: string, sql: string) => { - const db = openTestDb(path); - await db.exec(sql); - db.close(); -}; - -let workDir: string; - -beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), "exec-dbup-")); -}); - -afterEach(() => { - rmSync(workDir, { recursive: true, force: true }); -}); - -describe("isPreScopeSchema", () => { - it("returns true for a DB with a source table missing scope_id", async () => { - const path = join(workDir, "data.db"); - await seed(path, PRE_SCOPE_SCHEMA); - expect(await isPreScopeSchema(path)).toBe(true); - }); - - it("returns false for a DB whose source table already has scope_id", async () => { - const path = join(workDir, "data.db"); - await seed(path, SCOPED_SCHEMA); - expect(await isPreScopeSchema(path)).toBe(false); - }); - - it("returns false for a DB with no source table", async () => { - const path = join(workDir, "data.db"); - await seed(path, "CREATE TABLE unrelated (x TEXT);"); - expect(await isPreScopeSchema(path)).toBe(false); - }); - - it("returns false when the DB file doesn't exist", async () => { - expect(await isPreScopeSchema(join(workDir, "missing.db"))).toBe(false); - }); -}); - -describe("moveAsidePreScopeDb", () => { - it("renames data.db + wal/shm siblings and returns the backup path", async () => { - const path = join(workDir, "data.db"); - await seed(path, PRE_SCOPE_SCHEMA); - writeFileSync(`${path}-wal`, "wal-bytes"); - writeFileSync(`${path}-shm`, "shm-bytes"); - - const backup = await moveAsidePreScopeDb(path); - expect(backup).toMatch(/data\.db\.pre-scopes-\d+-[0-9a-f]{8}$/); - expect(existsSync(path)).toBe(false); - expect(existsSync(`${path}-wal`)).toBe(false); - expect(existsSync(`${path}-shm`)).toBe(false); - expect(existsSync(backup!)).toBe(true); - expect(existsSync(`${backup}-wal`)).toBe(true); - expect(existsSync(`${backup}-shm`)).toBe(true); - }); - - it("is a no-op when the DB already has the scoped schema", async () => { - const path = join(workDir, "data.db"); - await seed(path, SCOPED_SCHEMA); - expect(await moveAsidePreScopeDb(path)).toBeNull(); - expect(existsSync(path)).toBe(true); - }); - - it("is a no-op when the DB doesn't exist yet", async () => { - expect(await moveAsidePreScopeDb(join(workDir, "missing.db"))).toBeNull(); - }); -}); - -describe("move-aside + fresh migrate end-to-end", () => { - it("lets migrations run cleanly after an old DB is moved aside", async () => { - const path = join(workDir, "data.db"); - await seed(path, PRE_SCOPE_SCHEMA); - - const backup = await moveAsidePreScopeDb(path); - expect(backup).not.toBeNull(); - - await runMigrations(path, join(import.meta.dirname, "../../drizzle")); - const db = openTestDb(path); - const cols = (await db.prepare("PRAGMA table_info('source')").all()) as ReadonlyArray<{ - readonly name: string; - }>; - db.close(); - expect(cols.some((c) => c.name === "scope_id")).toBe(true); - }); -}); - -describe("readLegacySecrets", () => { - it("returns all rows from a pre-scope DB's secret table", async () => { - const path = join(workDir, "data.db"); - await seed(path, PRE_SCOPE_SCHEMA); - const db = openTestDb(path); - await db - .prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)") - .run("sec_1", "GitHub Token", "onepassword", 1_700_000_000); - await db - .prepare("INSERT INTO secret (id, name, provider, created_at) VALUES (?, ?, ?, ?)") - .run("sec_2", "Stripe", "keychain", 1_700_000_001); - db.close(); - - const rows = await readLegacySecrets(path); - expect(rows).toHaveLength(2); - expect(rows[0]).toEqual({ - id: "sec_1", - name: "GitHub Token", - provider: "onepassword", - createdAt: 1_700_000_000, - }); - }); - - it("returns [] when the DB has no secret table", async () => { - const path = join(workDir, "data.db"); - await seed(path, "CREATE TABLE unrelated (x TEXT);"); - expect(await readLegacySecrets(path)).toEqual([]); - }); - - it("returns [] when the DB file doesn't exist", async () => { - expect(await readLegacySecrets(join(workDir, "missing.db"))).toEqual([]); - }); -}); - -describe("importLegacySecrets", () => { - const createScopedDb = async (path: string) => { - const db = openTestDb(path); - await db.exec(` - CREATE TABLE secret ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - name TEXT NOT NULL, - provider TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - `); - return db; - }; - - it("inserts rows stamped with the given scope id", async () => { - const path = join(workDir, "data.db"); - const db = await createScopedDb(path); - await importLegacySecrets(db.client, "scope_a", [ - { id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }, - { id: "sec_2", name: "St", provider: "keychain", createdAt: 2 }, - ]); - const rows = await db - .prepare("SELECT id, scope_id, name, provider FROM secret ORDER BY id") - .all<{ id: string; scope_id: string; name: string; provider: string }>(); - db.close(); - expect(rows).toHaveLength(2); - expect(rows[0]).toEqual({ - id: "sec_1", - scope_id: "scope_a", - name: "GH", - provider: "onepassword", - }); - expect(rows[1].scope_id).toBe("scope_a"); - }); - - it("is a no-op with an empty list", async () => { - const path = join(workDir, "data.db"); - const db = await createScopedDb(path); - await importLegacySecrets(db.client, "scope_a", []); - const count = (await db.prepare("SELECT COUNT(*) as n FROM secret").get<{ n: number }>())?.n; - db.close(); - expect(count).toBe(0); - }); - - it("uses INSERT OR IGNORE so a second import of the same ids is a no-op", async () => { - const path = join(workDir, "data.db"); - const db = await createScopedDb(path); - const rows = [{ id: "sec_1", name: "GH", provider: "onepassword", createdAt: 1 }]; - await importLegacySecrets(db.client, "scope_a", rows); - await db - .prepare("UPDATE secret SET provider = 'file' WHERE id = 'sec_1' AND scope_id = 'scope_a'") - .run(); - await importLegacySecrets(db.client, "scope_a", rows); - const provider = ( - await db - .prepare("SELECT provider FROM secret WHERE id = ? AND scope_id = ?") - .get<{ provider: string }>("sec_1", "scope_a") - )?.provider; - db.close(); - expect(provider).toBe("file"); - }); -}); diff --git a/apps/local/src/db/db-upgrade.ts b/apps/local/src/db/db-upgrade.ts deleted file mode 100644 index 40ff1d66a..000000000 --- a/apps/local/src/db/db-upgrade.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Pre-scope-refactor executor CLI versions (<= 1.4.x) created a SQLite DB -// with a different shape: the `source` / `tool` / `definition` / `secret` -// tables had single-column `id` primary keys and no `scope_id` column. -// The scope-refactor added `scope_id` + composite `(scope_id, id)` PKs, -// which drizzle-kit generated as plain `CREATE TABLE` statements. That -// migration can't apply idempotently on top of an existing old-schema DB, -// so the upgrade path is to move the old file aside and let the fresh -// migration create the new shape. Users who need old data keep the -// backup; most never will — the rows are stale tool catalogs they'd -// re-fetch anyway. - -import { type Client } from "@libsql/client"; -import { randomBytes } from "node:crypto"; -import * as fs from "node:fs"; - -import { openLegacyLibsql, queryFirst, queryRows } from "./libsql"; - -/** - * Returns true when the DB at `dbPath` looks like it was written by a - * pre-scope executor — has a `source` table but no `scope_id` column. - * Fresh DBs (no `source` table yet) and current DBs both return false. - * - * Reads the legacy on-disk SQLite file through libSQL (same file format); - * readonly intent is enforced by issuing only SELECT/PRAGMA reads. - */ -export const isPreScopeSchema = async (dbPath: string): Promise => { - if (!fs.existsSync(dbPath)) return false; - const client = openLegacyLibsql(dbPath); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local SQLite schema probe must close the DB handle - try { - const tableExists = await queryFirst( - client, - "SELECT name FROM sqlite_master WHERE type='table' AND name='source'", - ); - if (!tableExists) return false; - const columns = await queryRows<{ readonly name: string }>( - client, - "PRAGMA table_info('source')", - ); - return !columns.some((c) => c.name === "scope_id"); - } finally { - client.close(); - } -}; - -/** - * Move a pre-scope DB (and its WAL/SHM siblings) aside to - * `.pre-scopes-`. Returns the backup path if anything - * was moved, otherwise null. - */ -export const moveAsidePreScopeDb = async (dbPath: string): Promise => { - if (!(await isPreScopeSchema(dbPath))) return null; - // Timestamp alone is near-unique; the random suffix makes it actually - // unique even if two moves ever land in the same millisecond. - const suffix = `${Date.now()}-${randomBytes(4).toString("hex")}`; - const backup = `${dbPath}.pre-scopes-${suffix}`; - for (const ext of ["", "-wal", "-shm"]) { - const src = dbPath + ext; - if (fs.existsSync(src)) fs.renameSync(src, backup + ext); - } - return backup; -}; - -// --------------------------------------------------------------------------- -// Legacy secret routing — the `secret` table in the pre-scope DB has rows -// mapping secret id → provider. The secret *values* live in the provider -// backends (keychain, 1password, file-secrets) and survive the move-aside -// untouched. But without the routing row, non-enumerating providers -// (keychain) become unreachable: `secretsGet`'s fallback loop only asks -// providers that expose `list()`. We copy those routing rows forward into -// the new DB so post-upgrade resolution keeps working seamlessly. -// --------------------------------------------------------------------------- - -export interface LegacySecret { - readonly id: string; - readonly name: string; - readonly provider: string; - readonly createdAt: number; -} - -export const readLegacySecrets = async (dbPath: string): Promise => { - if (!fs.existsSync(dbPath)) return []; - const client = openLegacyLibsql(dbPath); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local SQLite legacy-row read must close the DB handle - try { - const tableExists = await queryFirst( - client, - "SELECT name FROM sqlite_master WHERE type='table' AND name='secret'", - ); - if (!tableExists) return []; - return await queryRows( - client, - "SELECT id, name, provider, created_at as createdAt FROM secret", - ); - } finally { - client.close(); - } -}; - -/** - * Insert legacy routing rows into the new (scoped) `secret` table, - * stamping the current scope id. Idempotent — uses INSERT OR IGNORE so - * a row that the user already re-registered takes precedence. - */ -export const importLegacySecrets = async ( - client: Client, - scopeId: string, - secrets: readonly LegacySecret[], -): Promise => { - if (secrets.length === 0) return; - for (const s of secrets) { - await client.execute({ - sql: "INSERT OR IGNORE INTO secret (scope_id, id, name, provider, created_at) VALUES (?, ?, ?, ?, ?)", - args: [scopeId, s.id, s.name, s.provider, s.createdAt], - }); - } -}; diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index 7e508d44e..1db0b8c16 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -1,179 +1,165 @@ -import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"; +// --------------------------------------------------------------------------- +// Drizzle schema for the v2 executor core tables. +// +// This file exists ONLY to drive `drizzle-kit generate` for the committed +// migration baseline (`apps/local/drizzle`). It is NOT used at runtime: the +// local server brings its SQLite schema up directly from the FumaDB +// `coreTables` definition via `createDrizzleRuntimeSchemaSqlFromTables` +// (see `./sqlite-fumadb.ts`). It mirrors the column set FumaDB derives from +// `@executor-js/sdk`'s `coreTables` so the generated baseline matches the +// runtime schema. Keep it in sync if `coreTables` changes. +// --------------------------------------------------------------------------- -export const source = sqliteTable( - "source", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - plugin_id: text("plugin_id").notNull(), - kind: text("kind").notNull(), - name: text("name").notNull(), - url: text("url"), - can_remove: integer("can_remove", { mode: "boolean" }).default(true).notNull(), - can_refresh: integer("can_refresh", { mode: "boolean" }).default(false).notNull(), - can_edit: integer("can_edit", { mode: "boolean" }).default(false).notNull(), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("source_scope_id_idx").on(table.scope_id), - index("source_plugin_id_idx").on(table.plugin_id), - ], -); +import { sqliteTable, text, integer, blob, uniqueIndex } from "drizzle-orm/sqlite-core"; -export const tool = sqliteTable( - "tool", +export const integration = sqliteTable( + "integration", { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), + slug: text("slug").notNull(), plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), description: text("description").notNull(), - input_schema: text("input_schema", { mode: "json" }), - output_schema: text("output_schema", { mode: "json" }), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("tool_scope_id_idx").on(table.scope_id), - index("tool_source_id_idx").on(table.source_id), - index("tool_plugin_id_idx").on(table.plugin_id), - ], -); - -export const definition = sqliteTable( - "definition", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - plugin_id: text("plugin_id").notNull(), - name: text("name").notNull(), - schema: text("schema", { mode: "json" }).notNull(), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + config: text("config"), + can_remove: integer("can_remove").notNull().default(1), + can_refresh: integer("can_refresh").notNull().default(0), + created_at: integer("created_at").notNull(), + updated_at: integer("updated_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("definition_scope_id_idx").on(table.scope_id), - index("definition_source_id_idx").on(table.source_id), - index("definition_plugin_id_idx").on(table.plugin_id), - ], + (table) => [uniqueIndex("integration_uidx").on(table.tenant, table.slug)], ); -export const secret = sqliteTable( - "secret", +export const connection = sqliteTable( + "connection", { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), + integration: text("integration").notNull(), name: text("name").notNull(), + template: text("template").notNull(), provider: text("provider").notNull(), - owned_by_connection_id: text("owned_by_connection_id"), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + item_ids: text("item_ids").notNull(), + identity_label: text("identity_label"), + oauth_client: text("oauth_client"), + oauth_client_owner: text("oauth_client_owner"), + refresh_item_id: text("refresh_item_id"), + expires_at: blob("expires_at"), + oauth_scope: text("oauth_scope"), + provider_state: text("provider_state"), + created_at: integer("created_at").notNull(), + updated_at: integer("updated_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("secret_scope_id_idx").on(table.scope_id), - index("secret_provider_idx").on(table.provider), - index("secret_owned_by_connection_id_idx").on(table.owned_by_connection_id), + uniqueIndex("connection_uidx").on( + table.tenant, + table.owner, + table.subject, + table.integration, + table.name, + ), ], ); -export const connection = sqliteTable( - "connection", +export const oauth_client = sqliteTable( + "oauth_client", { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - provider: text("provider").notNull(), - identity_label: text("identity_label"), - access_token_secret_id: text("access_token_secret_id").notNull(), - refresh_token_secret_id: text("refresh_token_secret_id"), - expires_at: integer("expires_at"), - scope: text("scope"), - provider_state: text("provider_state", { mode: "json" }), - identity_override: text("identity_override", { mode: "json" }), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + slug: text("slug").notNull(), + authorization_url: text("authorization_url").notNull(), + token_url: text("token_url").notNull(), + grant: text("grant").notNull(), + client_id: text("client_id").notNull(), + client_secret_item_id: text("client_secret_item_id"), + resource: text("resource"), + origin_kind: text("origin_kind"), + origin_integration: text("origin_integration"), + created_at: integer("created_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("connection_scope_id_idx").on(table.scope_id), - index("connection_provider_idx").on(table.provider), + uniqueIndex("oauth_client_uidx").on(table.tenant, table.owner, table.subject, table.slug), ], ); -export const oauth2_session = sqliteTable( - "oauth2_session", +export const oauth_session = sqliteTable( + "oauth_session", { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - plugin_id: text("plugin_id").notNull(), - strategy: text("strategy").notNull(), - connection_id: text("connection_id").notNull(), - token_scope: text("token_scope").notNull(), + state: text("state").notNull(), + client_slug: text("client_slug").notNull(), + integration: text("integration").notNull(), + name: text("name").notNull(), + template: text("template").notNull(), redirect_url: text("redirect_url").notNull(), - payload: text("payload", { mode: "json" }).notNull(), - expires_at: integer("expires_at").notNull(), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), + pkce_verifier: text("pkce_verifier"), + identity_label: text("identity_label"), + payload: text("payload").notNull(), + expires_at: blob("expires_at").notNull(), + created_at: integer("created_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("oauth2_session_scope_id_idx").on(table.scope_id), - index("oauth2_session_plugin_id_idx").on(table.plugin_id), - index("oauth2_session_connection_id_idx").on(table.connection_id), - ], + (table) => [uniqueIndex("oauth_session_uidx").on(table.tenant, table.state)], ); -export const credential_binding = sqliteTable( - "credential_binding", +export const tool = sqliteTable( + "tool", { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), + integration: text("integration").notNull(), + connection: text("connection").notNull(), plugin_id: text("plugin_id").notNull(), - source_id: text("source_id").notNull(), - source_scope_id: text("source_scope_id").notNull(), - slot_key: text("slot_key").notNull(), - kind: text("kind").notNull(), - text_value: text("text_value"), - secret_id: text("secret_id"), - secret_scope_id: text("secret_scope_id"), - connection_id: text("connection_id"), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + name: text("name").notNull(), + description: text("description").notNull(), + input_schema: text("input_schema"), + output_schema: text("output_schema"), + annotations: text("annotations"), + created_at: integer("created_at").notNull(), + updated_at: integer("updated_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("credential_binding_scope_id_idx").on(table.scope_id), - index("credential_binding_plugin_id_idx").on(table.plugin_id), - index("credential_binding_source_id_idx").on(table.source_id), - index("credential_binding_source_scope_id_idx").on(table.source_scope_id), - index("credential_binding_slot_key_idx").on(table.slot_key), - index("credential_binding_kind_idx").on(table.kind), - index("credential_binding_secret_id_idx").on(table.secret_id), - index("credential_binding_secret_scope_id_idx").on(table.secret_scope_id), - index("credential_binding_connection_id_idx").on(table.connection_id), + uniqueIndex("tool_uidx").on( + table.tenant, + table.owner, + table.subject, + table.integration, + table.connection, + table.name, + ), ], ); -export const plugin_storage = sqliteTable( - "plugin_storage", +export const definition = sqliteTable( + "definition", { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), + integration: text("integration").notNull(), + connection: text("connection").notNull(), plugin_id: text("plugin_id").notNull(), - collection: text("collection").notNull(), - key: text("key").notNull(), - data: text("data", { mode: "json" }).notNull(), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + name: text("name").notNull(), + schema: text("schema").notNull(), + created_at: integer("created_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("plugin_storage_scope_id_idx").on(table.scope_id), - index("plugin_storage_plugin_id_collection_idx").on(table.plugin_id, table.collection), - index("plugin_storage_key_idx").on(table.key), + uniqueIndex("definition_uidx").on( + table.tenant, + table.owner, + table.subject, + table.integration, + table.connection, + table.name, + ), ], ); @@ -181,101 +167,55 @@ export const tool_policy = sqliteTable( "tool_policy", { id: text("id").notNull(), - scope_id: text("scope_id").notNull(), pattern: text("pattern").notNull(), action: text("action").notNull(), position: text("position").notNull(), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + created_at: integer("created_at").notNull(), + updated_at: integer("updated_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("tool_policy_scope_id_position_idx").on(table.scope_id, table.position), + uniqueIndex("tool_policy_uidx").on(table.tenant, table.owner, table.subject, table.id), ], ); -export const google_discovery_source = sqliteTable( - "google_discovery_source", +export const plugin_storage = sqliteTable( + "plugin_storage", { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - name: text("name").notNull(), - config: text("config", { mode: "json" }).notNull(), - auth_kind: text({ enum: ["none", "oauth2"] }) - .default("none") - .notNull(), - auth_connection_id: text("auth_connection_id"), - auth_client_id_secret_id: text("auth_client_id_secret_id"), - auth_client_secret_secret_id: text("auth_client_secret_secret_id"), - auth_scopes: text("auth_scopes", { mode: "json" }), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), - updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(), + plugin_id: text("plugin_id").notNull(), + collection: text("collection").notNull(), + key: text("key").notNull(), + data: text("data").notNull(), + created_at: integer("created_at").notNull(), + updated_at: integer("updated_at").notNull(), + row_id: text("row_id").primaryKey().notNull(), + tenant: text("tenant").notNull(), + owner: text("owner").notNull(), + subject: text("subject").notNull(), }, (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("google_discovery_source_scope_id_idx").on(table.scope_id), - index("google_discovery_source_auth_connection_id_idx").on(table.auth_connection_id), - index("google_discovery_source_auth_client_id_secret_id_idx").on( - table.auth_client_id_secret_id, + uniqueIndex("plugin_storage_uidx").on( + table.tenant, + table.owner, + table.subject, + table.plugin_id, + table.collection, + table.key, ), - index("google_discovery_source_auth_client_secret_secret_id_idx").on( - table.auth_client_secret_secret_id, - ), - ], -); - -export const google_discovery_source_credential_header = sqliteTable( - "google_discovery_source_credential_header", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "secret"] }).notNull(), - text_value: text("text_value"), - secret_id: text("secret_id"), - secret_prefix: text("secret_prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("google_discovery_source_credential_header_scope_id_idx").on(table.scope_id), - index("google_discovery_source_credential_header_source_id_idx").on(table.source_id), - index("google_discovery_source_credential_header_secret_id_idx").on(table.secret_id), - ], -); - -export const google_discovery_source_credential_query_param = sqliteTable( - "google_discovery_source_credential_query_param", - { - id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - name: text("name").notNull(), - kind: text({ enum: ["text", "secret"] }).notNull(), - text_value: text("text_value"), - secret_id: text("secret_id"), - secret_prefix: text("secret_prefix"), - }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("google_discovery_source_credential_query_param_scope_id_idx").on(table.scope_id), - index("google_discovery_source_credential_query_param_source_id_idx").on(table.source_id), - index("google_discovery_source_credential_query_param_secret_id_idx").on(table.secret_id), ], ); -export const google_discovery_binding = sqliteTable( - "google_discovery_binding", +export const blob_table = sqliteTable( + "blob", { + namespace: text("namespace").notNull(), + key: text("key").notNull(), + value: text("value").notNull(), + row_id: text("row_id").primaryKey().notNull(), id: text("id").notNull(), - scope_id: text("scope_id").notNull(), - source_id: text("source_id").notNull(), - binding: text("binding", { mode: "json" }).notNull(), - created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(), }, - (table) => [ - primaryKey({ columns: [table.scope_id, table.id] }), - index("google_discovery_binding_scope_id_idx").on(table.scope_id), - index("google_discovery_binding_source_id_idx").on(table.source_id), - ], + (table) => [uniqueIndex("blob_id_uidx").on(table.id)], ); diff --git a/apps/local/src/db/google-discovery-openapi-migration.test.ts b/apps/local/src/db/google-discovery-openapi-migration.test.ts deleted file mode 100644 index b66612e96..000000000 --- a/apps/local/src/db/google-discovery-openapi-migration.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { createClient, type Client } from "@libsql/client"; -import { Schema } from "effect"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { oneShotMigrateGoogleDiscoveryToOpenApi } from "./google-discovery-openapi-migration"; - -const encodeJson = (value: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(value)); -const MigratedSourceData = Schema.Struct({ - config: Schema.Struct({ - spec: Schema.String, - oauth2: Schema.optional(Schema.Struct({ connectionSlot: Schema.optional(Schema.String) })), - }), -}); -const MigratedOperation = Schema.Struct({ - operationId: Schema.optional(Schema.String), - "x-executor-toolPath": Schema.optional(Schema.String), - parameters: Schema.optional(Schema.Array(Schema.Unknown)), -}); -const MigratedSpec = Schema.Struct({ - paths: Schema.Record(Schema.String, Schema.Record(Schema.String, MigratedOperation)), -}); -const decodeMigratedSourceData = Schema.decodeUnknownSync( - Schema.fromJsonString(MigratedSourceData), -); -const decodeMigratedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(MigratedSpec)); - -// libSQL's `:memory:` opens a SEPARATE in-memory database per connection, so a -// write transaction (used by the one-shot migration) would not see the seeded -// tables. Back the fixture with a temp file so the migration's transaction -// shares the same database — matching local's real on-disk usage. -let fixtureDir: string; - -beforeEach(() => { - fixtureDir = mkdtempSync(join(tmpdir(), "gd-openapi-mig-")); -}); - -afterEach(() => { - rmSync(fixtureDir, { recursive: true, force: true }); -}); - -const createMigrationFixture = async (): Promise => { - const db = createClient({ url: `file:${join(fixtureDir, "data.db")}` }); - await db.executeMultiple(` - CREATE TABLE google_discovery_source ( - id text NOT NULL, - scope_id text NOT NULL, - name text NOT NULL, - config text NOT NULL, - auth_kind text NOT NULL, - auth_connection_id text, - auth_client_id_secret_id text, - auth_client_secret_secret_id text, - auth_scopes text, - created_at integer NOT NULL, - updated_at integer NOT NULL - ); - CREATE TABLE google_discovery_binding ( - id text NOT NULL, - scope_id text NOT NULL, - source_id text NOT NULL, - binding text NOT NULL, - created_at integer NOT NULL - ); - CREATE TABLE google_discovery_source_credential_header ( - id text NOT NULL, - scope_id text NOT NULL, - source_id text NOT NULL, - name text NOT NULL, - kind text NOT NULL, - text_value text, - secret_id text, - secret_prefix text - ); - CREATE TABLE google_discovery_source_credential_query_param ( - id text NOT NULL, - scope_id text NOT NULL, - source_id text NOT NULL, - name text NOT NULL, - kind text NOT NULL, - text_value text, - secret_id text, - secret_prefix text - ); - CREATE TABLE source ( - id text NOT NULL, - scope_id text NOT NULL, - plugin_id text NOT NULL, - kind text NOT NULL, - name text NOT NULL, - url text, - can_remove integer NOT NULL, - can_refresh integer NOT NULL, - can_edit integer NOT NULL, - created_at integer NOT NULL, - updated_at integer NOT NULL - ); - CREATE TABLE tool ( - id text NOT NULL, - scope_id text NOT NULL, - source_id text NOT NULL, - plugin_id text NOT NULL, - name text NOT NULL, - description text NOT NULL, - input_schema text, - output_schema text, - created_at integer NOT NULL, - updated_at integer NOT NULL - ); - CREATE TABLE definition ( - id text NOT NULL, - scope_id text NOT NULL, - source_id text NOT NULL, - plugin_id text NOT NULL, - name text NOT NULL, - schema text NOT NULL, - created_at integer NOT NULL - ); - CREATE TABLE plugin_storage ( - plugin_id text NOT NULL, - collection text NOT NULL, - key text NOT NULL, - data text NOT NULL, - created_at integer NOT NULL, - updated_at integer NOT NULL, - row_id text NOT NULL, - id text NOT NULL, - scope_id text NOT NULL - ); - CREATE TABLE credential_binding ( - plugin_id text NOT NULL, - source_id text NOT NULL, - source_scope_id text NOT NULL, - slot_key text NOT NULL, - kind text NOT NULL, - text_value text, - secret_id text, - secret_scope_id text, - connection_id text, - created_at integer NOT NULL, - updated_at integer NOT NULL, - row_id text NOT NULL, - id text NOT NULL, - scope_id text NOT NULL - ); - `); - return db; -}; - -describe("oneShotMigrateGoogleDiscoveryToOpenApi", () => { - it("moves a Google Discovery source into OpenAPI storage without changing tool ids", async () => { - const db = await createMigrationFixture(); - const now = 1_700_000_000; - const sourceId = "gmail_api"; - const scopeId = "local-scope"; - const toolId = `${sourceId}.users.messages.list`; - - await db.execute({ - sql: "INSERT INTO google_discovery_source (id, scope_id, name, config, auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - args: [ - sourceId, - scopeId, - "Gmail API", - encodeJson({ - discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", - service: "gmail", - version: "v1", - rootUrl: "https://gmail.googleapis.com/", - servicePath: "", - }), - "oauth2", - "google-discovery-oauth2-gmail_api", - "client-id-secret", - "client-secret-secret", - encodeJson(["https://www.googleapis.com/auth/gmail.metadata"]), - now, - now, - ], - }); - await db.execute({ - sql: "INSERT INTO source (id, scope_id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - args: [ - sourceId, - scopeId, - "googleDiscovery", - "googleDiscovery", - "Gmail API", - null, - 1, - 0, - 1, - now, - now, - ], - }); - await db.execute({ - sql: "INSERT INTO google_discovery_binding (id, scope_id, source_id, binding, created_at) VALUES (?, ?, ?, ?, ?)", - args: [ - toolId, - scopeId, - sourceId, - encodeJson({ - method: "get", - pathTemplate: "gmail/v1/users/{userId}/messages", - hasBody: false, - parameters: [ - { - name: "userId", - location: "path", - required: true, - repeated: false, - schema: { type: "string" }, - }, - { - name: "metadataHeaders", - location: "query", - required: false, - repeated: true, - schema: { type: "array", items: { type: "string" } }, - }, - ], - }), - now, - ], - }); - await db.execute({ - sql: "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - args: [ - toolId, - scopeId, - sourceId, - "googleDiscovery", - "users.messages.list", - "Lists messages.", - encodeJson({ - type: "object", - properties: { - userId: { type: "string" }, - metadataHeaders: { type: "array", items: { type: "string" } }, - }, - }), - encodeJson({ $ref: "#/$defs/ListMessagesResponse" }), - now, - now, - ], - }); - await db.execute({ - sql: "INSERT INTO definition (id, scope_id, source_id, plugin_id, name, schema, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - args: [ - `${sourceId}.ListMessagesResponse`, - scopeId, - sourceId, - "googleDiscovery", - "ListMessagesResponse", - encodeJson({ type: "object", properties: { messages: { type: "array" } } }), - now, - ], - }); - - const migrated = await oneShotMigrateGoogleDiscoveryToOpenApi(db); - - expect(migrated).toBe(1); - expect( - (await db.execute("SELECT count(*) AS n FROM google_discovery_source")).rows[0], - ).toMatchObject({ n: 0 }); - expect( - ( - await db.execute({ - sql: "SELECT plugin_id, kind, url, can_refresh FROM source WHERE id = ?", - args: [sourceId], - }) - ).rows[0], - ).toMatchObject({ - plugin_id: "openapi", - kind: "openapi", - url: "https://gmail.googleapis.com/", - can_refresh: 0, - }); - expect( - (await db.execute({ sql: "SELECT plugin_id FROM tool WHERE id = ?", args: [toolId] })) - .rows[0], - ).toMatchObject({ - plugin_id: "openapi", - }); - - // oxlint-disable-next-line executor/no-double-cast -- boundary: the SELECT column is the schema contract for this plugin_storage row read off the libSQL client - const sourceStorage = ( - await db.execute({ - sql: "SELECT data FROM plugin_storage WHERE collection = 'source' AND key = ?", - args: [sourceId], - }) - ).rows[0] as unknown as { data: string }; - const sourceData = decodeMigratedSourceData(sourceStorage.data); - const spec = decodeMigratedSpec(sourceData.config.spec); - const operation = spec.paths["/gmail/v1/users/{userId}/messages"]?.get; - expect(operation).toMatchObject({ - operationId: "users.messages.list", - "x-executor-toolPath": "users.messages.list", - }); - expect(operation?.parameters).toContainEqual( - expect.objectContaining({ - name: "metadataHeaders", - in: "query", - style: "form", - explode: true, - }), - ); - expect(sourceData.config.oauth2).toMatchObject({ - connectionSlot: "oauth2:googleoauth2:connection", - }); - - expect( - (await db.execute("SELECT key FROM plugin_storage WHERE collection = 'operation'")).rows[0], - ).toMatchObject({ key: toolId }); - const credentialBindings = ( - await db.execute( - "SELECT slot_key, kind, secret_id, connection_id FROM credential_binding ORDER BY slot_key", - ) - ).rows.map((row) => ({ - slot_key: row.slot_key, - kind: row.kind, - secret_id: row.secret_id, - connection_id: row.connection_id, - })); - expect(credentialBindings).toEqual([ - { - slot_key: "oauth2:googleoauth2:client-id", - kind: "secret", - secret_id: "client-id-secret", - connection_id: null, - }, - { - slot_key: "oauth2:googleoauth2:client-secret", - kind: "secret", - secret_id: "client-secret-secret", - connection_id: null, - }, - { - slot_key: "oauth2:googleoauth2:connection", - kind: "connection", - secret_id: null, - connection_id: "google-discovery-oauth2-gmail_api", - }, - ]); - - db.close(); - }); -}); diff --git a/apps/local/src/db/google-discovery-openapi-migration.ts b/apps/local/src/db/google-discovery-openapi-migration.ts deleted file mode 100644 index 1109a574c..000000000 --- a/apps/local/src/db/google-discovery-openapi-migration.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { type Client, type InValue } from "@libsql/client"; -import { Option, Schema } from "effect"; -import { randomBytes } from "node:crypto"; - -import { queryFirst, queryRows } from "./libsql"; - -const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); - -const GoogleDiscoveryConfig = Schema.Struct({ - discoveryUrl: Schema.optional(Schema.String), - service: Schema.optional(Schema.String), - version: Schema.optional(Schema.String), - rootUrl: Schema.String, - servicePath: Schema.optional(Schema.String), -}); - -const GoogleDiscoveryParameter = Schema.Struct({ - name: Schema.String, - location: Schema.String, - required: Schema.optional(Schema.Boolean), - repeated: Schema.optional(Schema.Boolean), - description: Schema.optional(Schema.String), - schema: Schema.optional(Schema.Unknown), -}); - -const GoogleDiscoveryBinding = Schema.Struct({ - method: Schema.String, - pathTemplate: Schema.String, - hasBody: Schema.optional(Schema.Boolean), - parameters: Schema.optional(Schema.Array(GoogleDiscoveryParameter)), -}); - -const JsonInputSchema = Schema.Struct({ - properties: Schema.optional(UnknownRecord), -}); - -const GoogleDiscoveryScopes = Schema.Array(Schema.String); - -const decodeGoogleDiscoveryConfig = Schema.decodeUnknownOption( - Schema.fromJsonString(GoogleDiscoveryConfig), -); -const decodeGoogleDiscoveryBinding = Schema.decodeUnknownOption( - Schema.fromJsonString(GoogleDiscoveryBinding), -); -const decodeInputSchema = Schema.decodeUnknownOption(Schema.fromJsonString(JsonInputSchema)); -const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); -const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecord); -const decodeGoogleDiscoveryScopes = Schema.decodeUnknownOption( - Schema.fromJsonString(GoogleDiscoveryScopes), -); - -type GoogleDiscoveryConfig = typeof GoogleDiscoveryConfig.Type; -type GoogleDiscoveryBinding = typeof GoogleDiscoveryBinding.Type; - -type GoogleSourceRow = { - readonly id: string; - readonly scope_id: string; - readonly name: string; - readonly config: string; - readonly auth_kind: string; - readonly auth_connection_id: string | null; - readonly auth_client_id_secret_id: string | null; - readonly auth_client_secret_secret_id: string | null; - readonly auth_scopes: string | null; - readonly created_at: number; - readonly updated_at: number; -}; - -type GoogleBindingRow = { - readonly id: string; - readonly scope_id: string; - readonly source_id: string; - readonly binding: string; -}; - -type ToolRow = { - readonly id: string; - readonly name: string; - readonly description: string; - readonly input_schema: string | null; - readonly output_schema: string | null; -}; - -type DefinitionRow = { - readonly name: string; - readonly schema: string; -}; - -type CredentialRow = { - readonly name: string; - readonly kind: string; - readonly text_value: string | null; - readonly secret_id: string | null; - readonly secret_prefix: string | null; -}; - -type MigratedCredentialBinding = - | { - readonly slot: string; - readonly kind: "secret"; - readonly secretId: string; - readonly prefix?: string | null; - } - | { - readonly slot: string; - readonly kind: "connection"; - readonly connectionId: string; - }; - -type OpenApiParameter = { - readonly name: string; - readonly location: string; - readonly required: boolean; - readonly schema: unknown; - readonly style?: "form"; - readonly explode?: boolean; - readonly description?: string; -}; - -const textDecoder = new TextDecoder(); - -// libSQL returns BLOB columns as ArrayBuffer (legacy rows stored JSON as bytes), -// TEXT columns as string. Normalize both to text before JSON-decoding. -const decodeJsonColumnOption =
( - decode: (value: unknown) => Option.Option, - value: string | Uint8Array | ArrayBuffer | null | undefined, -): Option.Option => { - if (!value) return Option.none(); - const text = - typeof value === "string" - ? value - : textDecoder.decode(value instanceof ArrayBuffer ? new Uint8Array(value) : value); - return decode(text); -}; - -const decodeJsonColumnOrUndefined = ( - value: string | Uint8Array | ArrayBuffer | null | undefined, -): unknown | undefined => Option.getOrUndefined(decodeJsonColumnOption(decodeUnknownJson, value)); - -const recordFromUnknown = (value: unknown): Record => - Option.getOrElse(decodeUnknownRecord(value), () => ({})); - -const nonEmptyStringOrUndefined = (value: string | undefined): string | undefined => - value && value.length > 0 ? value : undefined; - -const googleSchemaRef = (name: string): string => `#/$defs/${name}`; - -const openApiPluginStorageId = (collection: string, key: string): string => - JSON.stringify(["openapi", collection, key]); - -const openApiCredentialBindingId = (scopeId: string, sourceId: string, slot: string): string => - JSON.stringify(["openapi", scopeId, sourceId, slot]); - -const slugifyCredentialSlotPart = (value: string): string => - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "default"; - -const openApiHeaderSlot = (name: string): string => `header:${slugifyCredentialSlotPart(name)}`; - -const openApiQueryParamSlot = (name: string): string => - `query_param:${slugifyCredentialSlotPart(name)}`; - -const googleOAuthSecuritySchemeName = "googleOAuth2"; - -const googleOAuthSlotPart = slugifyCredentialSlotPart(googleOAuthSecuritySchemeName); - -const googleOAuthClientIdSlot = `oauth2:${googleOAuthSlotPart}:client-id`; -const googleOAuthClientSecretSlot = `oauth2:${googleOAuthSlotPart}:client-secret`; -const googleOAuthConnectionSlot = `oauth2:${googleOAuthSlotPart}:connection`; - -const randomRowId = (): string => randomBytes(12).toString("hex"); - -const googleCredentialMap = ( - rows: readonly CredentialRow[], - slotForName: (name: string) => string, - bindings: MigratedCredentialBinding[], -): Record | undefined => { - const values: Record = {}; - for (const row of rows) { - if (row.kind === "text" && row.text_value != null) { - values[row.name] = row.text_value; - continue; - } - if (row.kind === "secret" && row.secret_id != null) { - const slot = slotForName(row.name); - values[row.name] = - row.secret_prefix != null - ? { kind: "binding", slot, prefix: row.secret_prefix } - : { kind: "binding", slot }; - bindings.push({ - slot, - kind: "secret", - secretId: row.secret_id, - prefix: row.secret_prefix, - }); - } - } - return Object.keys(values).length > 0 ? values : undefined; -}; - -const readSourceConfig = (source: GoogleSourceRow): GoogleDiscoveryConfig | null => { - const decoded = decodeJsonColumnOption(decodeGoogleDiscoveryConfig, source.config); - if (Option.isNone(decoded)) return null; - return decoded.value; -}; - -const readBinding = (row: GoogleBindingRow): GoogleDiscoveryBinding | null => { - const decoded = decodeJsonColumnOption(decodeGoogleDiscoveryBinding, row.binding); - if (Option.isNone(decoded)) return null; - return decoded.value; -}; - -const readBodySchema = (tool: ToolRow | undefined): Record => { - const decoded = decodeJsonColumnOption(decodeInputSchema, tool?.input_schema); - if (Option.isNone(decoded)) return {}; - return recordFromUnknown(decoded.value.properties?.body); -}; - -const readScopes = (value: string | null): readonly string[] => - Option.getOrElse(decodeJsonColumnOption(decodeGoogleDiscoveryScopes, value), () => []); - -const openApiParameters = ( - parameters: readonly (typeof GoogleDiscoveryParameter.Type)[] | undefined, -): readonly OpenApiParameter[] => - (parameters ?? []).map((parameter) => ({ - name: parameter.name, - location: parameter.location, - required: parameter.location === "path" ? true : parameter.required === true, - schema: parameter.schema ?? { type: "string" }, - ...(parameter.location === "query" - ? { style: "form" as const, explode: parameter.repeated === true } - : {}), - ...(parameter.description ? { description: parameter.description } : {}), - })); - -// One-shot startup migration over the LIVE libSQL handle (same file the app -// runs on). Each source migrates inside its own write transaction so a failure -// leaves that source atomic; reads and the BEGIN/COMMIT/ROLLBACK block move from -// synchronous bun:sqlite to async `client.execute` / `client.transaction`. -export const oneShotMigrateGoogleDiscoveryToOpenApi = async (client: Client): Promise => { - const table = await queryFirst( - client, - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - ["google_discovery_source"], - ); - if (!table) return 0; - - const sources = await queryRows( - client, - "SELECT * FROM google_discovery_source ORDER BY scope_id, id", - ); - let migrated = 0; - const migrateSource = async (source: GoogleSourceRow): Promise => { - const config = readSourceConfig(source); - if (!config) return false; - - const baseUrl = new URL(config.servicePath ?? "", config.rootUrl).toString(); - const service = nonEmptyStringOrUndefined(config.service) ?? source.id; - const version = nonEmptyStringOrUndefined(config.version) ?? "v1"; - const discoveryUrl = nonEmptyStringOrUndefined(config.discoveryUrl); - - const bindings = await queryRows( - client, - "SELECT * FROM google_discovery_binding WHERE scope_id = ? AND source_id = ? ORDER BY id", - [source.scope_id, source.id], - ); - if (bindings.length === 0) return false; - - const toolRows = new Map( - ( - await queryRows( - client, - "SELECT id, name, description, input_schema, output_schema FROM tool WHERE scope_id = ? AND source_id = ?", - [source.scope_id, source.id], - ) - ).map((row) => [row.id, row] as const), - ); - const definitions = await queryRows( - client, - "SELECT name, schema FROM definition WHERE scope_id = ? AND source_id = ? ORDER BY name", - [source.scope_id, source.id], - ); - - const paths: Record> = {}; - const operationRows: Array<{ readonly toolId: string; readonly binding: unknown }> = []; - - for (const row of bindings) { - const binding = readBinding(row); - if (!binding) continue; - - const method = binding.method.toLowerCase(); - const pathTemplate = binding.pathTemplate.startsWith("/") - ? binding.pathTemplate - : `/${binding.pathTemplate}`; - const tool = toolRows.get(row.id); - const toolPath = tool?.name ?? row.id.slice(source.id.length + 1); - const bodySchema = readBodySchema(tool); - const responseSchema = decodeJsonColumnOrUndefined(tool?.output_schema) ?? {}; - const parameters = openApiParameters(binding.parameters); - - paths[pathTemplate] ??= {}; - paths[pathTemplate]![method] = { - operationId: toolPath, - "x-executor-toolPath": toolPath, - ...(tool?.description ? { description: tool.description } : {}), - parameters: parameters.map(({ location, ...parameter }) => ({ - ...parameter, - in: location, - })), - ...(binding.hasBody === true - ? { - requestBody: { - required: false, - content: { - "application/json": { - schema: Object.keys(bodySchema).length > 0 ? bodySchema : { type: "object" }, - }, - }, - }, - } - : {}), - responses: { - "200": { - description: "Successful response", - content: { - "application/json": { - schema: responseSchema, - }, - }, - }, - }, - }; - - operationRows.push({ - toolId: row.id, - binding: { - method, - pathTemplate, - parameters, - ...(binding.hasBody === true - ? { - requestBody: { - required: false, - contentType: "application/json", - schema: Object.keys(bodySchema).length > 0 ? bodySchema : { type: "object" }, - contents: [ - { - contentType: "application/json", - schema: Object.keys(bodySchema).length > 0 ? bodySchema : { type: "object" }, - }, - ], - }, - } - : {}), - }, - }); - } - - if (operationRows.length === 0) return false; - - const schemaDefinitions = Object.fromEntries( - definitions.map((definition) => [ - definition.name, - decodeJsonColumnOrUndefined(definition.schema) ?? { - $ref: googleSchemaRef(definition.name), - }, - ]), - ); - const scopes = readScopes(source.auth_scopes); - const oauth2 = - source.auth_kind === "oauth2" && source.auth_connection_id && source.auth_client_id_secret_id - ? { - kind: "oauth2", - securitySchemeName: googleOAuthSecuritySchemeName, - flow: "authorizationCode", - authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", - issuerUrl: "https://accounts.google.com", - tokenUrl: "https://oauth2.googleapis.com/token", - clientIdSlot: googleOAuthClientIdSlot, - clientSecretSlot: source.auth_client_secret_secret_id - ? googleOAuthClientSecretSlot - : null, - connectionSlot: googleOAuthConnectionSlot, - scopes, - } - : undefined; - - const spec = { - openapi: "3.1.0", - info: { title: source.name, version }, - servers: [{ url: baseUrl }], - paths, - components: { - schemas: schemaDefinitions, - ...(oauth2 - ? { - securitySchemes: { - [googleOAuthSecuritySchemeName]: { - type: "oauth2", - flows: { - authorizationCode: { - authorizationUrl: oauth2.authorizationUrl, - tokenUrl: oauth2.tokenUrl, - scopes: Object.fromEntries(scopes.map((scope) => [scope, ""])), - }, - }, - }, - }, - } - : {}), - }, - ...(oauth2 ? { security: [{ [googleOAuthSecuritySchemeName]: scopes }] } : {}), - "x-executor-origin": { - kind: "googleDiscovery", - ...(discoveryUrl ? { discoveryUrl } : {}), - service, - version, - }, - }; - - const credentialBindings: MigratedCredentialBinding[] = []; - - const headerRows = await queryRows( - client, - "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", - [source.scope_id, source.id], - ); - const queryParamRows = await queryRows( - client, - "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", - [source.scope_id, source.id], - ); - const headers = googleCredentialMap(headerRows, openApiHeaderSlot, credentialBindings); - const queryParams = googleCredentialMap( - queryParamRows, - openApiQueryParamSlot, - credentialBindings, - ); - - if (oauth2 && source.auth_client_id_secret_id) { - credentialBindings.push({ - slot: googleOAuthClientIdSlot, - kind: "secret", - secretId: source.auth_client_id_secret_id, - }); - if (source.auth_client_secret_secret_id) { - credentialBindings.push({ - slot: googleOAuthClientSecretSlot, - kind: "secret", - secretId: source.auth_client_secret_secret_id, - }); - } - if (source.auth_connection_id) { - credentialBindings.push({ - slot: googleOAuthConnectionSlot, - kind: "connection", - connectionId: source.auth_connection_id, - }); - } - } - - const now = Math.floor(Date.now() / 1000); - const sourceData = { - namespace: source.id, - scope: source.scope_id, - name: source.name, - config: { - spec: JSON.stringify(spec), - baseUrl, - namespace: source.id, - ...(headers ? { headers } : {}), - ...(queryParams ? { queryParams } : {}), - ...(oauth2 ? { oauth2 } : {}), - }, - }; - - // Each source migrates atomically: a libSQL write transaction replaces the - // bun:sqlite BEGIN IMMEDIATE / COMMIT / ROLLBACK block. - const tx = await client.transaction("write"); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: one-shot startup migration should leave each source atomic on write failure - try { - await tx.execute({ - sql: "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - args: [ - "openapi", - "source", - source.id, - JSON.stringify(sourceData), - source.created_at ?? now, - now, - randomRowId(), - openApiPluginStorageId("source", source.id), - source.scope_id, - ] satisfies InValue[], - }); - - for (const operation of operationRows) { - await tx.execute({ - sql: "INSERT OR REPLACE INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - args: [ - "openapi", - "operation", - operation.toolId, - JSON.stringify({ - toolId: operation.toolId, - sourceId: source.id, - binding: operation.binding, - }), - source.created_at ?? now, - now, - randomRowId(), - openApiPluginStorageId("operation", operation.toolId), - source.scope_id, - ] satisfies InValue[], - }); - } - - for (const binding of credentialBindings) { - const secretId = binding.kind === "secret" ? binding.secretId : null; - const secretScopeId = binding.kind === "secret" ? source.scope_id : null; - const connectionId = binding.kind === "connection" ? binding.connectionId : null; - await tx.execute({ - sql: "INSERT OR REPLACE INTO credential_binding (plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, secret_scope_id, connection_id, created_at, updated_at, row_id, id, scope_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - args: [ - "openapi", - source.id, - source.scope_id, - binding.slot, - binding.kind, - null, - secretId, - secretScopeId, - connectionId, - now, - now, - randomRowId(), - openApiCredentialBindingId(source.scope_id, source.id, binding.slot), - source.scope_id, - ] satisfies InValue[], - }); - } - - await tx.execute({ - sql: "UPDATE source SET plugin_id = ?, kind = ?, url = ?, can_refresh = ?, can_edit = ?, updated_at = ? WHERE scope_id = ? AND id = ?", - args: ["openapi", "openapi", baseUrl, 0, 1, now, source.scope_id, source.id], - }); - await tx.execute({ - sql: "UPDATE tool SET plugin_id = ?, updated_at = ? WHERE scope_id = ? AND source_id = ?", - args: ["openapi", now, source.scope_id, source.id], - }); - await tx.execute({ - sql: "UPDATE definition SET plugin_id = ? WHERE scope_id = ? AND source_id = ?", - args: ["openapi", source.scope_id, source.id], - }); - await tx.execute({ - sql: "DELETE FROM google_discovery_binding WHERE scope_id = ? AND source_id = ?", - args: [source.scope_id, source.id], - }); - await tx.execute({ - sql: "DELETE FROM google_discovery_source_credential_header WHERE scope_id = ? AND source_id = ?", - args: [source.scope_id, source.id], - }); - await tx.execute({ - sql: "DELETE FROM google_discovery_source_credential_query_param WHERE scope_id = ? AND source_id = ?", - args: [source.scope_id, source.id], - }); - await tx.execute({ - sql: "DELETE FROM google_discovery_source WHERE scope_id = ? AND id = ?", - args: [source.scope_id, source.id], - }); - await tx.commit(); - } catch (cause) { - await tx.rollback(); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the migration rolls back then preserves the original startup failure - throw cause; - } - - return true; - }; - - for (const source of sources) { - if (await migrateSource(source)) { - migrated++; - } - } - - if (migrated > 0) { - await client.execute("PRAGMA wal_checkpoint(TRUNCATE)"); - } - return migrated; -}; diff --git a/apps/local/src/db/libsql.ts b/apps/local/src/db/libsql.ts index 2eae6823d..d8d82e92f 100644 --- a/apps/local/src/db/libsql.ts +++ b/apps/local/src/db/libsql.ts @@ -2,31 +2,23 @@ import { createClient, type Client, type InArgs, type ResultSet } from "@libsql/ import { resolve } from "node:path"; // --------------------------------------------------------------------------- -// libSQL connection helpers for the local server. The local CLI/daemon used to -// open a single in-process bun:sqlite handle that drizzle and the legacy -// importers shared; libSQL instead opens a connection per `createClient`, so -// the per-connection PRAGMAs (foreign_keys, WAL) must be re-applied on every -// client (they no longer carry over from one shared handle). These helpers -// centralize the `file:` URL construction and the per-connection PRAGMA set so -// every open site stays consistent. -// -// libSQL reads existing on-disk SQLite files (the legacy pre-FumaDB / pre-scope -// databases) directly via a `file:` URL — same file format — so the one-time -// legacy import/migration path works against the same files, just through the -// async libSQL client instead of synchronous bun:sqlite. +// libSQL connection helper for the local server. libSQL opens a connection per +// `createClient`, so the per-connection PRAGMAs (foreign_keys, WAL) must be +// re-applied on every client (they no longer carry over from one shared +// handle). This helper centralizes the `file:` URL construction and the +// per-connection PRAGMA set so every open site stays consistent. // --------------------------------------------------------------------------- /** * Build a libSQL `file:` URL from a filesystem path. libSQL requires an * absolute path for `file:` URLs; `:memory:` passes through unchanged. */ -export const toLibsqlFileUrl = (path: string): string => +const toLibsqlFileUrl = (path: string): string => path === ":memory:" ? path : `file:${resolve(path)}`; /** * Open a libSQL client for a local on-disk DB and apply the per-connection - * PRAGMAs (foreign_keys + WAL). Used for the long-lived FumaDB handle and the - * live one-shot google-discovery migration. + * PRAGMAs (foreign_keys + WAL). Used for the long-lived FumaDB handle. */ export const openLocalLibsql = async (path: string): Promise => { const client = createClient({ url: toLibsqlFileUrl(path) }); @@ -37,36 +29,21 @@ export const openLocalLibsql = async (path: string): Promise => { return client; }; -/** - * Open a libSQL client for reading a legacy on-disk SQLite file. Readonly - * intent is enforced by issuing only SELECT/PRAGMA reads (libSQL has no - * per-open readonly flag in the bun:sqlite sense). - */ -export const openLegacyLibsql = (path: string): Client => - createClient({ url: toLibsqlFileUrl(path) }); - -// --------------------------------------------------------------------------- -// Typed query boundary. `@libsql/client` returns rows as the structural `Row` -// type (array-like with named getters). The legacy importers/probes read known -// column shapes off those rows, so this is the single place where the dynamic -// SQLite result is narrowed to the caller's row type — the SQL is the schema -// contract, mirroring what bun:sqlite's `query()` generic provided. -// --------------------------------------------------------------------------- - const asRows = (result: ResultSet): readonly T[] => - // oxlint-disable-next-line executor/no-double-cast -- boundary: the SQLite result columns are the schema contract for `T`; libSQL's `Row` is structurally the row, narrowed once here + // oxlint-disable-next-line executor/no-double-cast -- boundary: SQLite result columns are the schema contract for T; libSQL rows are narrowed once here result.rows as unknown as readonly T[]; -/** Run a SELECT and return its rows narrowed to `T` (the SQL is the contract). */ +export const executeSql = async (client: Client, sql: string, args?: InArgs): Promise => + client.execute(args ? { sql, args } : sql); + export const queryRows = async ( client: Client, sql: string, args?: InArgs, -): Promise => asRows(await client.execute(args ? { sql, args } : sql)); +): Promise => asRows(await executeSql(client, sql, args)); -/** Run a SELECT and return its first row narrowed to `T`, or undefined. */ export const queryFirst = async ( client: Client, sql: string, args?: InArgs, -): Promise => (await queryRows(client, sql, args))[0]; +): Promise => (await queryRows(client, sql, args))[0] ?? null; diff --git a/apps/local/src/db/migrate-google-discovery-bindings.test.ts b/apps/local/src/db/migrate-google-discovery-bindings.test.ts deleted file mode 100644 index d7ff10b13..000000000 --- a/apps/local/src/db/migrate-google-discovery-bindings.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -// End-to-end test for the google-discovery portion of -// `0007_normalize_plugin_secret_refs.sql`. Seeds a -// google_discovery_source row with the legacy json shape (config -// containing auth/credentials), runs the migration, asserts the new -// columns and child tables are populated. - -import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Schema } from "effect"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -import { openTestDb, runMigrations } from "../testing/libsql-test-db"; -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; - -const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); - -const migratedConfig = Schema.Struct({ - auth: Schema.optional(Schema.Unknown), - service: Schema.String, -}); -const decodeMigratedConfig = Schema.decodeUnknownSync(Schema.fromJsonString(migratedConfig)); - -const tempDirs = new Set(); - -const createTempDbPath = () => { - const dir = mkdtempSync(join(tmpdir(), "gd-mig-")); - tempDirs.add(dir); - return join(dir, "test.sqlite"); -}; - -describe("0007_normalize_plugin_secret_refs (google-discovery)", () => { - afterEach(() => { - for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); - } - tempDirs.clear(); - }); - - it("flattens oauth2 auth into columns", async () => { - const dbPath = createTempDbPath(); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "drive", - "Drive", - JSON.stringify({ - name: "Drive", - discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", - service: "drive", - version: "v3", - rootUrl: "https://www.googleapis.com/", - servicePath: "drive/v3/", - auth: { - kind: "oauth2", - connectionId: "conn-1", - clientIdSecretId: "client-id", - clientSecretSecretId: "client-secret", - scopes: ["https://www.googleapis.com/auth/drive"], - }, - }), - Date.now(), - Date.now(), - ); - - db.close(); - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const row = (await after - .prepare( - "SELECT auth_kind, auth_connection_id, auth_client_id_secret_id, auth_client_secret_secret_id, auth_scopes, config FROM google_discovery_source WHERE id = ?", - ) - .get("drive")) as Record; - expect(row.auth_kind).toBe("oauth2"); - expect(row.auth_connection_id).toBe("conn-1"); - expect(row.auth_client_id_secret_id).toBe("client-id"); - expect(row.auth_client_secret_secret_id).toBe("client-secret"); - // auth_scopes column is text-typed (string[] gets stored as JSON in sqlite). - expect(row.auth_scopes).toContain("drive"); - // The auth key should be stripped from config json. - const config = decodeMigratedConfig(row.config); - expect(config.auth).toBeUndefined(); - expect(config.service).toBe("drive"); - after.close(); - }); - - it("explodes credentials.headers and queryParams into child rows", async () => { - const dbPath = createTempDbPath(); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "with-creds", - "With Creds", - JSON.stringify({ - name: "With Creds", - discoveryUrl: "https://example.com/discovery", - service: "svc", - version: "v1", - rootUrl: "https://example.com/", - servicePath: "svc/v1/", - auth: { kind: "none" }, - credentials: { - headers: { - "X-Static": "literal", - Authorization: { secretId: "tok-secret", prefix: "Bearer " }, - }, - queryParams: { - api_key: { secretId: "key-secret" }, - }, - }, - }), - Date.now(), - Date.now(), - ); - - db.close(); - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const headers = (await after - .prepare( - "SELECT name, kind, text_value, secret_id, secret_prefix FROM google_discovery_source_credential_header WHERE source_id = ? ORDER BY name", - ) - .all("with-creds")) as ReadonlyArray>; - expect(headers).toHaveLength(2); - const byName = new Map(headers.map((h) => [h.name!, h])); - expect(byName.get("X-Static")).toMatchObject({ - kind: "text", - text_value: "literal", - }); - expect(byName.get("Authorization")).toMatchObject({ - kind: "secret", - secret_id: "tok-secret", - secret_prefix: "Bearer ", - }); - - const params = (await after - .prepare( - "SELECT name, secret_id FROM google_discovery_source_credential_query_param WHERE source_id = ?", - ) - .all("with-creds")) as ReadonlyArray>; - expect(params).toHaveLength(1); - expect(params[0]).toMatchObject({ name: "api_key", secret_id: "key-secret" }); - - after.close(); - }); - - it("survives auth.kind=none with no credentials", async () => { - const dbPath = createTempDbPath(); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO google_discovery_source (scope_id, id, name, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "bare", - "Bare", - JSON.stringify({ - name: "Bare", - discoveryUrl: "https://example.com/discovery", - service: "svc", - version: "v1", - rootUrl: "https://example.com/", - servicePath: "svc/v1/", - auth: { kind: "none" }, - }), - Date.now(), - Date.now(), - ); - - db.close(); - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const row = (await after - .prepare( - "SELECT auth_kind, auth_connection_id, auth_scopes FROM google_discovery_source WHERE id = ?", - ) - .get("bare")) as Record; - expect(row.auth_kind).toBe("none"); - expect(row.auth_connection_id).toBeNull(); - - const headerCount = ( - (await after - .prepare( - "SELECT count(*) as n FROM google_discovery_source_credential_header WHERE source_id = ?", - ) - .get("bare")) as { n: number } - ).n; - expect(headerCount).toBe(0); - after.close(); - }); -}); diff --git a/apps/local/src/db/migrate-graphql-bindings.test.ts b/apps/local/src/db/migrate-graphql-bindings.test.ts deleted file mode 100644 index c3f7d563b..000000000 --- a/apps/local/src/db/migrate-graphql-bindings.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -// End-to-end test for the graphql portion of -// GraphQL credential migrations: seed a DB at the pre-migration shape -// with json-blob headers/query_params/auth, run all migrations, and -// assert the final slot model plus shared credential_binding rows. - -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Schema } from "effect"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -import { openTestDb, runMigrations } from "../testing/libsql-test-db"; -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; - -const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); - -const NullableString = Schema.NullOr(Schema.String); - -const TableInfoRow = Schema.Struct({ - name: Schema.String, -}); - -const BindingRow = Schema.Struct({ - scope_id: Schema.String, - plugin_id: Schema.String, - source_id: Schema.String, - source_scope_id: Schema.String, - slot_key: Schema.String, - kind: Schema.String, - secret_id: NullableString, - connection_id: NullableString, -}); - -const PluginStorageRow = Schema.Struct({ data: Schema.String }); - -const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow)); -const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow)); -const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); -const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); - -let dir: string; - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "graphql-mig-")); -}); - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }); -}); - -describe("graphql credential migrations", () => { - it("moves auth json connection refs into a connection slot binding", async () => { - const dbPath = join(dir, "test.sqlite"); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, auth) VALUES (?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "github", - "GitHub", - "https://api.github.com/graphql", - JSON.stringify({ kind: "oauth2", connectionId: "conn-1" }), - ); - - db.close(); - - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("graphql", "source", "github"), - ).data, - ) as { readonly auth: { readonly kind: string; readonly connectionSlot?: string } }; - expect(source.auth.kind).toBe("oauth2"); - expect(source.auth.connectionSlot).toBe("auth:oauth2:connection"); - const bindings = decodeBindingRows( - await after - .prepare( - "SELECT scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? ORDER BY slot_key", - ) - .all("graphql"), - ); - expect(bindings).toEqual([ - { - scope_id: "default-scope", - plugin_id: "graphql", - source_id: "github", - source_scope_id: "default-scope", - slot_key: "auth:oauth2:connection", - kind: "connection", - secret_id: null, - connection_id: "conn-1", - }, - ]); - // Old json column is gone. - const cols = decodeTableInfoRows( - await after.prepare("PRAGMA table_info('graphql_source')").all(), - ); - expect(cols.some((c) => c.name === "auth")).toBe(false); - expect(cols.some((c) => c.name === "headers")).toBe(false); - expect(cols.some((c) => c.name === "query_params")).toBe(false); - expect(cols.some((c) => c.name === "auth_connection_id")).toBe(false); - after.close(); - }); - - it("explodes header/query_param json into slots and credential bindings", async () => { - const dbPath = join(dir, "test.sqlite"); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - const headers = { - // Literal text header. - "X-Static": "literal-value", - // Secret-backed header without prefix. - Authorization: { secretId: "sec-token" }, - // Secret-backed with prefix. - "X-Bearer": { secretId: "sec-bearer", prefix: "Bearer " }, - }; - const queryParams = { - api_key: { secretId: "sec-key" }, - }; - - await db - .prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, query_params, auth) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "example", - "Example", - "https://example.com/graphql", - JSON.stringify(headers), - JSON.stringify(queryParams), - JSON.stringify({ kind: "none" }), - ); - - db.close(); - - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("graphql", "source", "example"), - ).data, - ) as { - readonly headers: Record; - readonly queryParams: Record; - }; - expect(source.headers).toMatchObject({ - "X-Static": "literal-value", - Authorization: { kind: "binding", slot: "header:authorization" }, - "X-Bearer": { kind: "binding", slot: "header:x-bearer", prefix: "Bearer " }, - }); - expect(source.queryParams.api_key).toMatchObject({ - kind: "binding", - slot: "query_param:api-key", - }); - - const bindings = decodeBindingRows( - await after - .prepare( - "SELECT scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? ORDER BY slot_key", - ) - .all("graphql"), - ); - expect(bindings.map((binding) => [binding.slot_key, binding.secret_id])).toEqual([ - ["header:authorization", "sec-token"], - ["header:x-bearer", "sec-bearer"], - ["query_param:api-key", "sec-key"], - ]); - - after.close(); - }); - - it("fails instead of silently collapsing colliding legacy query parameter slots", async () => { - const dbPath = join(dir, "test.sqlite"); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, query_params, auth) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "collision", - "Collision", - "https://example.com/graphql", - JSON.stringify({ - api_key: { secretId: "sec-underscore" }, - "api-key": { secretId: "sec-dash" }, - }), - JSON.stringify({ kind: "none" }), - ); - - db.close(); - - await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); - }); - - it("fails instead of silently collapsing colliding legacy header slots", async () => { - const dbPath = join(dir, "test.sqlite"); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers, auth) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "collision", - "Collision", - "https://example.com/graphql", - JSON.stringify({ - x_token: { secretId: "sec-underscore" }, - "x-token": { secretId: "sec-dash" }, - }), - JSON.stringify({ kind: "none" }), - ); - - db.close(); - - await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); - }); - - it("handles graphql_source rows with null json (empty config)", async () => { - const dbPath = join(dir, "test.sqlite"); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare("INSERT INTO graphql_source (scope_id, id, name, endpoint) VALUES (?, ?, ?, ?)") - .run("default-scope", "bare", "Bare", "https://bare.example/graphql"); - db.close(); - - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("graphql", "source", "bare"), - ).data, - ) as { readonly auth: { readonly kind: string }; readonly headers: Record }; - expect(source.auth.kind).toBe("none"); - expect(source.headers).toEqual({}); - after.close(); - }); - - it("does not collapse child rows whose source/name pairs share colon-concatenated ids", async () => { - const dbPath = join(dir, "test.sqlite"); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - const insert = db.prepare( - "INSERT INTO graphql_source (scope_id, id, name, endpoint, headers) VALUES (?, ?, ?, ?, ?)", - ); - await insert.run( - "default-scope", - "a:b", - "First", - "https://first.example/graphql", - JSON.stringify({ c: "first" }), - ); - await insert.run( - "default-scope", - "a", - "Second", - "https://second.example/graphql", - JSON.stringify({ "b:c": "second" }), - ); - db.close(); - - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const rows = ( - await after - .prepare( - "SELECT key, data FROM plugin_storage WHERE plugin_id = ? AND collection = ? ORDER BY key", - ) - .all<{ key: string; data: string }>("graphql", "source") - ).map((row) => { - const decoded = decodePluginStorageRow(row); - return { - key: row.key, - data: decodePluginStorageData(decoded.data), - }; - }) as ReadonlyArray<{ - readonly key: string; - readonly data: { readonly headers: Record }; - }>; - expect(rows.map((row) => row.key)).toEqual(["a", "a:b"]); - expect(rows[0]?.data.headers).toEqual({ "b:c": "second" }); - expect(rows[1]?.data.headers).toEqual({ c: "first" }); - after.close(); - }); -}); diff --git a/apps/local/src/db/migrate-mcp-bindings.test.ts b/apps/local/src/db/migrate-mcp-bindings.test.ts deleted file mode 100644 index b73c63020..000000000 --- a/apps/local/src/db/migrate-mcp-bindings.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -// End-to-end tests for the MCP credential migrations. These seed the old -// config JSON shape, run the full migration runner, and assert the final -// runtime model only contains source-owned slots plus core credential_binding -// rows. - -import { afterEach, describe, expect, it } from "@effect/vitest"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { Schema } from "effect"; - -import { openTestDb, runMigrations } from "../testing/libsql-test-db"; -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; - -const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); - -const PluginStorageRow = Schema.Struct({ data: Schema.String }); -const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); -const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); - -const tempDirs: Array = []; - -const makeDbPath = () => { - const dir = mkdtempSync(join(tmpdir(), "mcp-mig-")); - tempDirs.push(dir); - return join(dir, "test.sqlite"); -}; - -describe("mcp credential migrations", () => { - afterEach(() => { - for (const dir of tempDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it("moves header auth into an auth slot and credential binding", async () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "remote-headers", - "Remote Headers", - JSON.stringify({ - transport: "remote", - endpoint: "https://example.com/mcp", - auth: { - kind: "header", - headerName: "X-API-Key", - secretId: "tok-secret", - prefix: "Bearer ", - }, - }), - Date.now(), - ); - - db.close(); - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("mcp", "source", "remote-headers"), - ).data, - ) as { - readonly config: { - readonly auth?: { - readonly kind: string; - readonly headerName?: string; - readonly secretSlot?: string; - readonly prefix?: string; - }; - readonly endpoint?: string; - readonly transport: string; - }; - }; - expect(source.config.auth).toMatchObject({ - kind: "header", - headerName: "X-API-Key", - secretSlot: "auth:header", - prefix: "Bearer ", - }); - const binding = (await after - .prepare( - "SELECT slot_key, kind, secret_id FROM credential_binding WHERE plugin_id = ? AND source_id = ? AND slot_key = ?", - ) - .get("mcp", "remote-headers", "auth:header")) as Record; - expect(binding).toMatchObject({ - slot_key: "auth:header", - kind: "secret", - secret_id: "tok-secret", - }); - expect(source.config.transport).toBe("remote"); - expect(source.config.endpoint).toBe("https://example.com/mcp"); - after.close(); - }); - - it("moves oauth2 auth and request credentials into slots and bindings", async () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "remote-oauth", - "Remote OAuth", - JSON.stringify({ - transport: "remote", - endpoint: "https://oauth.example/mcp", - headers: { - "X-Trace": "static", - "X-Token": { secretId: "extra-tok" }, - }, - queryParams: { - org: { secretId: "org-id-secret" }, - }, - auth: { - kind: "oauth2", - connectionId: "conn-1", - clientIdSecretId: "client-id-sec", - clientSecretSecretId: "client-secret-sec", - }, - }), - Date.now(), - ); - - db.close(); - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("mcp", "source", "remote-oauth"), - ).data, - ) as { - readonly config: { - readonly auth?: Record; - readonly headers?: Record; - readonly queryParams?: Record; - }; - }; - expect(source.config.auth).toMatchObject({ - kind: "oauth2", - connectionSlot: "auth:oauth2:connection", - clientIdSlot: "auth:oauth2:client-id", - clientSecretSlot: "auth:oauth2:client-secret", - }); - - const authBindings = (await after - .prepare( - "SELECT slot_key, kind, secret_id, connection_id FROM credential_binding WHERE plugin_id = ? AND source_id = ? ORDER BY slot_key", - ) - .all("mcp", "remote-oauth")) as ReadonlyArray>; - const bySlot = new Map(authBindings.map((binding) => [binding.slot_key, binding])); - expect(bySlot.get("auth:oauth2:connection")).toMatchObject({ - kind: "connection", - connection_id: "conn-1", - }); - expect(bySlot.get("auth:oauth2:client-id")).toMatchObject({ - kind: "secret", - secret_id: "client-id-sec", - }); - expect(bySlot.get("auth:oauth2:client-secret")).toMatchObject({ - kind: "secret", - secret_id: "client-secret-sec", - }); - - expect(source.config.headers).toMatchObject({ - "X-Trace": "static", - "X-Token": { kind: "binding", slot: "header:x-token" }, - }); - expect(bySlot.get("header:x-token")).toMatchObject({ - kind: "secret", - secret_id: "extra-tok", - }); - - expect(source.config.queryParams?.org).toMatchObject({ - kind: "binding", - slot: "query_param:org", - }); - expect(bySlot.get("query_param:org")).toMatchObject({ - kind: "secret", - secret_id: "org-id-secret", - }); - - after.close(); - }); - - it("fails instead of silently collapsing colliding legacy header slots", async () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "collision", - "Collision", - JSON.stringify({ - transport: "remote", - endpoint: "https://example.com/mcp", - headers: { - x_token: { secretId: "sec-underscore" }, - "x-token": { secretId: "sec-dash" }, - }, - }), - Date.now(), - ); - - db.close(); - await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); - }); - - it("leaves stdio sources alone (no auth, no headers, no queryParams)", async () => { - const dbPath = makeDbPath(); - const db = openTestDb(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO mcp_source (scope_id, id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "stdio-only", - "Stdio", - JSON.stringify({ - transport: "stdio", - command: "/usr/bin/server", - args: ["--flag"], - }), - Date.now(), - ); - - db.close(); - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openTestDb(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("mcp", "source", "stdio-only"), - ).data, - ) as { readonly config: { readonly transport: string; readonly command?: string } }; - expect(source.config.transport).toBe("stdio"); - expect(source.config.command).toBe("/usr/bin/server"); - after.close(); - }); -}); diff --git a/apps/local/src/db/migrate-oauth-connections.test.ts b/apps/local/src/db/migrate-oauth-connections.test.ts deleted file mode 100644 index b0b4f8af4..000000000 --- a/apps/local/src/db/migrate-oauth-connections.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { openTestDb, type LibsqlTestDb } from "../testing/libsql-test-db"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Schema } from "effect"; - -const MIGRATION = join(import.meta.dirname, "../../drizzle/0008_scoped_credentials_cutover.sql"); -const REPAIR_MIGRATION = join( - import.meta.dirname, - "../../drizzle/0009_repair_openapi_oauth_cutover_residue.sql", -); - -let workDir: string; -let db: LibsqlTestDb; - -beforeEach(async () => { - workDir = mkdtempSync(join(tmpdir(), "executor-oauth-conn-mig-")); - db = openTestDb(join(workDir, "data.db")); - await db.exec(` - CREATE TABLE \`connection\` ( - \`id\` text NOT NULL, - \`scope_id\` text NOT NULL, - \`provider\` text NOT NULL, - \`provider_state\` text, - \`scope\` text, - \`updated_at\` integer NOT NULL - ); - `); -}); - -afterEach(() => { - db.close(); - rmSync(workDir, { recursive: true, force: true }); -}); - -const ConnectionRow = Schema.Struct({ - provider: Schema.String, - provider_state: Schema.String, -}); -const decodeConnectionRows = Schema.decodeUnknownSync(Schema.Array(ConnectionRow)); -const decodeJsonRecord = Schema.decodeUnknownSync( - Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), -); - -const oauthConnectionMigrationSql = () => { - const sql = readFileSync(MIGRATION, "utf-8"); - const start = sql.indexOf("-- 0013_normalize_oauth_connections.sql"); - const end = sql.indexOf("-- 0014_openapi_header_rows.sql", start); - if (start < 0 || end < 0) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: test fixture helper must fail fast when the migration section marker changes - throw new Error("OAuth connection migration section not found"); - } - return sql.slice(start, end); -}; - -describe("0008_scoped_credentials_cutover OAuth connection section", () => { - it("rewrites old OAuth provider keys and provider_state into the canonical oauth2 shape", async () => { - const now = Date.now(); - const insert = await db.prepare( - "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ); - await insert.run( - "openapi-conn", - "scope-1", - "openapi:oauth2", - JSON.stringify({ - flow: "authorizationCode", - tokenUrl: "https://openapi.example.com/token", - clientIdSecretId: "openapi-client-id", - clientSecretSecretId: null, - scopes: ["read"], - }), - "read", - now, - ); - await insert.run( - "mcp-conn", - "scope-1", - "mcp:oauth2", - JSON.stringify({ - endpoint: "https://mcp.example.com/mcp", - tokenEndpoint: "https://mcp.example.com/token", - clientInformation: { - client_id: "mcp-client", - token_endpoint_auth_method: "client_secret_basic", - }, - }), - null, - now, - ); - await insert.run( - "google-conn", - "scope-1", - "google-discovery:oauth2", - JSON.stringify({ - clientIdSecretId: "google-client-id", - clientSecretSecretId: "google-client-secret", - scopes: ["https://www.googleapis.com/auth/drive"], - }), - "https://www.googleapis.com/auth/drive", - now, - ); - - await db.exec(oauthConnectionMigrationSql()); - - const rows = decodeConnectionRows( - await db.prepare("SELECT provider, provider_state FROM `connection` ORDER BY id").all(), - ); - expect(rows.map((row) => row.provider)).toEqual(["oauth2", "oauth2", "oauth2"]); - const [google, mcp, openapi] = rows.map((row) => decodeJsonRecord(row.provider_state)); - expect(google).toMatchObject({ - kind: "authorization-code", - tokenEndpoint: "https://oauth2.googleapis.com/token", - clientIdSecretId: "google-client-id", - }); - expect(mcp).toMatchObject({ - kind: "dynamic-dcr", - tokenEndpoint: "https://mcp.example.com/token", - clientId: "mcp-client", - clientAuth: "basic", - resource: "https://mcp.example.com/mcp", - }); - expect(openapi).toMatchObject({ - kind: "authorization-code", - tokenEndpoint: "https://openapi.example.com/token", - clientIdSecretId: "openapi-client-id", - scopes: ["read"], - scope: "read", - }); - }); -}); - -describe("0009_repair_openapi_oauth_cutover_residue", () => { - it("repairs already-canonical OpenAPI rows and restores user-scoped OAuth secret bindings", async () => { - const now = Date.now(); - await db.exec(` - CREATE TABLE \`openapi_source\` ( - \`id\` text NOT NULL, - \`scope_id\` text NOT NULL, - \`oauth2\` text, - PRIMARY KEY(\`scope_id\`, \`id\`) - ); - CREATE TABLE \`credential_binding\` ( - \`id\` text NOT NULL, - \`scope_id\` text NOT NULL, - \`plugin_id\` text NOT NULL, - \`source_id\` text NOT NULL, - \`source_scope_id\` text NOT NULL, - \`slot_key\` text NOT NULL, - \`kind\` text NOT NULL, - \`text_value\` text, - \`secret_id\` text, - \`connection_id\` text, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL, - PRIMARY KEY(\`scope_id\`, \`id\`) - ); - `); - - await db.prepare("INSERT INTO `openapi_source` (id, scope_id, oauth2) VALUES (?, ?, ?)").run( - "example_api", - "org-1", - JSON.stringify({ - kind: "oauth2", - securitySchemeName: "oauth2", - clientIdSlot: "oauth2:oauth2:client-id", - clientSecretSlot: "oauth2:oauth2:client-secret", - connectionSlot: "oauth2:oauth2:connection", - }), - ); - - const insertConnection = await db.prepare( - "INSERT INTO `connection` (id, scope_id, provider, provider_state, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - ); - await insertConnection.run( - "openapi-oauth2-app-example_api", - "org-1", - "oauth2", - JSON.stringify({ - kind: "client-credentials", - tokenEndpoint: "https://auth.example.test/oauth/token", - clientIdSecretId: "example-client-id", - clientSecretSecretId: "example-client-secret", - }), - null, - now, - ); - await insertConnection.run( - "openapi-oauth2-app-example_api", - "user-org:user-jd:org-1", - "openapi:oauth2", - JSON.stringify({ - kind: "client-credentials", - tokenEndpoint: "https://auth.example.test/oauth/token", - clientIdSecretId: "example-client-id-jd", - clientSecretSecretId: "example-client-secret-jd", - }), - null, - now, - ); - - const insertBinding = await db.prepare( - "INSERT INTO `credential_binding` (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ); - await insertBinding.run( - "org-client-id", - "org-1", - "openapi", - "example_api", - "org-1", - "oauth2:oauth2:client-id", - "secret", - null, - "example-client-id-jd", - null, - now, - now, - ); - await insertBinding.run( - "org-client-secret", - "org-1", - "openapi", - "example_api", - "org-1", - "oauth2:oauth2:client-secret", - "secret", - null, - "example-client-secret-jd", - null, - now, - now, - ); - await insertBinding.run( - "org-connection", - "org-1", - "openapi", - "example_api", - "org-1", - "oauth2:oauth2:connection", - "connection", - null, - null, - "openapi-oauth2-app-example_api", - now, - now, - ); - await insertBinding.run( - "jd-connection", - "user-org:user-jd:org-1", - "openapi", - "example_api", - "org-1", - "oauth2:oauth2:connection", - "connection", - null, - null, - "openapi-oauth2-app-example_api", - now, - now, - ); - - await db.exec(readFileSync(REPAIR_MIGRATION, "utf-8")); - - const providers = decodeConnectionRows( - await db.prepare("SELECT provider, provider_state FROM `connection` ORDER BY scope_id").all(), - ); - expect(providers.map((row) => row.provider)).toEqual(["oauth2", "oauth2"]); - - const bindings = ( - await db - .prepare( - "SELECT scope_id, slot_key, kind, secret_id, connection_id FROM `credential_binding` WHERE source_id = ? ORDER BY scope_id, slot_key", - ) - .all("example_api") - ).map((row) => ({ - scope_id: row.scope_id, - slot_key: row.slot_key, - kind: row.kind, - secret_id: row.secret_id, - connection_id: row.connection_id, - })); - expect(bindings).toEqual([ - { - scope_id: "org-1", - slot_key: "oauth2:oauth2:client-id", - kind: "secret", - secret_id: "example-client-id", - connection_id: null, - }, - { - scope_id: "org-1", - slot_key: "oauth2:oauth2:client-secret", - kind: "secret", - secret_id: "example-client-secret", - connection_id: null, - }, - { - scope_id: "org-1", - slot_key: "oauth2:oauth2:connection", - kind: "connection", - secret_id: null, - connection_id: "openapi-oauth2-app-example_api", - }, - { - scope_id: "user-org:user-jd:org-1", - slot_key: "oauth2:oauth2:client-id", - kind: "secret", - secret_id: "example-client-id-jd", - connection_id: null, - }, - { - scope_id: "user-org:user-jd:org-1", - slot_key: "oauth2:oauth2:client-secret", - kind: "secret", - secret_id: "example-client-secret-jd", - connection_id: null, - }, - { - scope_id: "user-org:user-jd:org-1", - slot_key: "oauth2:oauth2:connection", - kind: "connection", - secret_id: null, - connection_id: "openapi-oauth2-app-example_api", - }, - ]); - }); -}); diff --git a/apps/local/src/db/migrate-openapi-bindings.test.ts b/apps/local/src/db/migrate-openapi-bindings.test.ts deleted file mode 100644 index 010c91501..000000000 --- a/apps/local/src/db/migrate-openapi-bindings.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -// End-to-end test for the openapi portion of -// openapi credential migrations. Seeds the pre-0007 shape -// shape (json blobs on openapi_source.headers/query_params, -// openapi_source.invocation_config.specFetchCredentials.*, and -// openapi_source_binding.value), runs the migration runner, asserts -// child rows and shared credential bindings match the old data. - -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { Schema } from "effect"; - -import { LibsqlTestDb, openTestDb, runMigrations } from "../testing/libsql-test-db"; -import { PRE_0007_SQL, stampPriorMigrationsApplied } from "../testing/pre-0007-schema"; - -const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle"); - -const BindingRow = Schema.Struct({ - id: Schema.String, - scope_id: Schema.String, - plugin_id: Schema.String, - source_id: Schema.String, - source_scope_id: Schema.String, - slot_key: Schema.String, - kind: Schema.String, - secret_id: Schema.NullOr(Schema.String), - connection_id: Schema.NullOr(Schema.String), - text_value: Schema.NullOr(Schema.String), -}); - -const PluginStorageRow = Schema.Struct({ - data: Schema.String, -}); - -const CountRow = Schema.Struct({ - n: Schema.Number, -}); - -const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow)); -const decodeCountRow = Schema.decodeUnknownSync(CountRow); -const decodePluginStorageRow = Schema.decodeUnknownSync(PluginStorageRow); -const decodePluginStorageData = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); - -describe("0007_normalize_plugin_secret_refs (openapi)", () => { - let dir: string; - let dbPath: string; - let openDatabases: Set; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "openapi-mig-")); - dbPath = join(dir, "test.sqlite"); - openDatabases = new Set(); - }); - - afterEach(() => { - for (const db of openDatabases) { - db.close(); - } - openDatabases.clear(); - rmSync(dir, { recursive: true, force: true }); - }); - - const openDatabase = (path: string) => { - const db = openTestDb(path); - openDatabases.add(db); - return db; - }; - - const closeDatabase = (db: LibsqlTestDb) => { - db.close(); - openDatabases.delete(db); - }; - - it("moves openapi_source_binding rows into shared credential_binding", async () => { - const db = openDatabase(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - // Seed three bindings, one per kind. - const insert = await db.prepare( - "INSERT INTO openapi_source_binding (id, source_id, source_scope_id, target_scope_id, slot, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - ); - const now = Date.now(); - await insert.run( - "b1", - "src", - "default-scope", - "default-scope", - "header:authorization", - JSON.stringify({ kind: "secret", secretId: "tok-secret" }), - now, - now, - ); - await insert.run( - "b2", - "src", - "default-scope", - "default-scope", - "oauth2:default:connection", - JSON.stringify({ kind: "connection", connectionId: "conn-1" }), - now, - now, - ); - await insert.run( - "b3", - "src", - "default-scope", - "default-scope", - "header:x-static", - JSON.stringify({ kind: "text", text: "literal" }), - now, - now, - ); - - // Need the parent openapi_source row so the source_id FK ergonomics - // are satisfied for any cascading delete logic, though the binding - // table has no DB-level FK, code paths assume the parent exists. - await db - .prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", - ) - .run("default-scope", "src", "Source", "{}", "{}"); - - closeDatabase(db); - - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openDatabase(dbPath); - const rows = decodeBindingRows( - await after - .prepare( - "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding ORDER BY id", - ) - .all(), - ); - expect(rows).toHaveLength(3); - expect(rows[0]).toMatchObject({ - id: '["openapi","default-scope","src","header:authorization"]', - scope_id: "default-scope", - plugin_id: "openapi", - source_id: "src", - source_scope_id: "default-scope", - slot_key: "header:authorization", - kind: "secret", - secret_id: "tok-secret", - connection_id: null, - text_value: null, - }); - expect(rows[1]).toMatchObject({ - id: '["openapi","default-scope","src","header:x-static"]', - scope_id: "default-scope", - plugin_id: "openapi", - source_id: "src", - source_scope_id: "default-scope", - slot_key: "header:x-static", - kind: "text", - secret_id: null, - connection_id: null, - text_value: "literal", - }); - expect(rows[2]).toMatchObject({ - id: '["openapi","default-scope","src","oauth2:default:connection"]', - scope_id: "default-scope", - plugin_id: "openapi", - source_id: "src", - source_scope_id: "default-scope", - slot_key: "oauth2:default:connection", - kind: "connection", - secret_id: null, - connection_id: "conn-1", - text_value: null, - }); - const oldTableCount = decodeCountRow( - await after - .prepare( - "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_binding'", - ) - .get(), - ); - expect(oldTableCount.n).toBe(0); - }); - - it("explodes query_params and specFetchCredentials json into child slot rows", async () => { - const db = openDatabase(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - const queryParams = { - api_key: { secretId: "qp-secret" }, - flag: "true", - }; - const invocationConfig = { - specFetchCredentials: { - headers: { - Authorization: { secretId: "fetch-tok", prefix: "Bearer " }, - }, - queryParams: { token: { secretId: "fetch-qp" } }, - }, - }; - - await db - .prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "src", - "Source", - "{}", - JSON.stringify(queryParams), - JSON.stringify(invocationConfig), - ); - - closeDatabase(db); - - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openDatabase(dbPath); - - const sourceData = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("openapi", "source", "src"), - ).data, - ) as { - readonly config: { - readonly queryParams?: Record; - readonly specFetchCredentials?: { - readonly headers?: Record; - readonly queryParams?: Record; - }; - }; - }; - expect(sourceData.config.queryParams).toMatchObject({ - api_key: { kind: "binding", slot: "query_param:api-key" }, - flag: "true", - }); - expect(sourceData.config.specFetchCredentials?.headers?.Authorization).toMatchObject({ - kind: "binding", - slot: "spec_fetch_header:authorization", - prefix: "Bearer ", - }); - expect(sourceData.config.specFetchCredentials?.queryParams?.token).toMatchObject({ - kind: "binding", - slot: "spec_fetch_query_param:token", - }); - const oldQueryParamTableCount = decodeCountRow( - await after - .prepare( - "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_query_param'", - ) - .get(), - ); - expect(oldQueryParamTableCount.n).toBe(0); - - const bindings = decodeBindingRows( - await after - .prepare( - "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key", - ) - .all("src"), - ); - expect(bindings.map((row) => [row.slot_key, row.kind, row.secret_id])).toEqual([ - ["query_param:api-key", "secret", "qp-secret"], - ["spec_fetch_header:authorization", "secret", "fetch-tok"], - ["spec_fetch_query_param:token", "secret", "fetch-qp"], - ]); - - const oldSourceTableCount = decodeCountRow( - await after - .prepare( - "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", - ) - .get(), - ); - expect(oldSourceTableCount.n).toBe(0); - }); - - it("fails instead of silently collapsing colliding legacy query parameter slots", async () => { - const db = openDatabase(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "collision", - "Collision", - "{}", - JSON.stringify({ - api_key: { secretId: "sec-underscore" }, - "api-key": { secretId: "sec-dash" }, - }), - "{}", - ); - - closeDatabase(db); - - await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); - }); - - it("fails on punctuation collisions that runtime canonicalization would collapse", async () => { - const db = openDatabase(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - await db - .prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, query_params, invocation_config) VALUES (?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "punctuation-collision", - "Punctuation Collision", - "{}", - JSON.stringify({ - "X@Token": { secretId: "sec-at" }, - "X-Token": { secretId: "sec-dash" }, - }), - "{}", - ); - - closeDatabase(db); - - await expect(runMigrations(dbPath, MIGRATIONS_FOLDER)).rejects.toThrow(); - }); - - it("rewrites old OpenAPI header and OAuth JSON into slot config plus core bindings", async () => { - const db = openDatabase(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - const headers = { - Authorization: { secretId: "header-token", prefix: "Bearer " }, - "X-Static": "literal", - "X-Already": { kind: "binding", slot: "header:x-already" }, - }; - const oauth2 = { - kind: "oauth2", - connectionId: "conn-1", - securitySchemeName: "oauth2", - flow: "authorizationCode", - tokenUrl: "https://auth.example.com/token", - authorizationUrl: "https://auth.example.com/authorize", - issuerUrl: "https://auth.example.com", - clientIdSecretId: "client-id", - clientSecretSecretId: "client-secret", - scopes: ["read"], - }; - - await db - .prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, headers, oauth2, invocation_config) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .run( - "default-scope", - "src", - "Source", - "{}", - JSON.stringify(headers), - JSON.stringify(oauth2), - JSON.stringify({}), - ); - - closeDatabase(db); - - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openDatabase(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("openapi", "source", "src"), - ).data, - ) as { - readonly config: { - readonly headers?: Record; - readonly oauth2?: Record; - }; - }; - expect(source.config.headers).toMatchObject({ - Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, - "X-Static": "literal", - "X-Already": { kind: "binding", slot: "header:x-already" }, - }); - const oldHeaderTableCount = decodeCountRow( - await after - .prepare( - "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source_header'", - ) - .get(), - ); - expect(oldHeaderTableCount.n).toBe(0); - - const migratedOAuth2 = source.config.oauth2 ?? {}; - expect(migratedOAuth2).toMatchObject({ - kind: "oauth2", - securitySchemeName: "oauth2", - clientIdSlot: "oauth2:oauth2:client-id", - clientSecretSlot: "oauth2:oauth2:client-secret", - connectionSlot: "oauth2:oauth2:connection", - }); - expect(migratedOAuth2).not.toHaveProperty("connectionId"); - expect(migratedOAuth2).not.toHaveProperty("clientIdSecretId"); - - const bindings = decodeBindingRows( - await after - .prepare( - "SELECT id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, secret_id, connection_id, text_value FROM credential_binding WHERE source_id = ? ORDER BY slot_key", - ) - .all("src"), - ); - expect( - bindings.map((row) => [row.slot_key, row.kind, row.secret_id, row.connection_id]), - ).toEqual([ - ["header:authorization", "secret", "header-token", null], - ["oauth2:oauth2:client-id", "secret", "client-id", null], - ["oauth2:oauth2:client-secret", "secret", "client-secret", null], - ["oauth2:oauth2:connection", "connection", null, "conn-1"], - ]); - - const oldSourceTableCount = decodeCountRow( - await after - .prepare( - "SELECT count(*) as n FROM sqlite_master WHERE type = 'table' AND name = 'openapi_source'", - ) - .get(), - ); - expect(oldSourceTableCount.n).toBe(0); - }); - - it("survives empty / missing json on bindings and sources", async () => { - const db = openDatabase(dbPath); - await db.exec(PRE_0007_SQL); - await stampPriorMigrationsApplied(db); - - // Source with empty invocation_config and no query_params. - await db - .prepare( - "INSERT INTO openapi_source (scope_id, id, name, spec, invocation_config) VALUES (?, ?, ?, ?, ?)", - ) - .run("default-scope", "bare", "Bare", "{}", JSON.stringify({})); - - closeDatabase(db); - await runMigrations(dbPath, MIGRATIONS_FOLDER); - - const after = openDatabase(dbPath); - const source = decodePluginStorageData( - decodePluginStorageRow( - await after - .prepare( - "SELECT data FROM plugin_storage WHERE plugin_id = ? AND collection = ? AND key = ?", - ) - .get("openapi", "source", "bare"), - ).data, - ) as { readonly config: { readonly queryParams?: unknown } }; - expect(source.config.queryParams).toBeUndefined(); - }); -}); diff --git a/apps/local/src/db/sqlite-fumadb.ts b/apps/local/src/db/sqlite-fumadb.ts index b3d21b9ee..c1af652d6 100644 --- a/apps/local/src/db/sqlite-fumadb.ts +++ b/apps/local/src/db/sqlite-fumadb.ts @@ -58,10 +58,16 @@ export const createSqliteFumaDb = async ( await client.execute(statement); } - // Defensive column add for libSQL files created before connection identity - // overrides existed — the bring-up above is CREATE TABLE IF NOT EXISTS and - // won't add a column to an already-created table. Idempotent. + // Defensive column adds for libSQL files created by earlier v2 baselines — + // the bring-up above is CREATE TABLE IF NOT EXISTS and won't add a column to + // an already-created table. Idempotent. const connectionColumns = await client.execute("PRAGMA table_info('connection')"); + if ( + connectionColumns.rows.length > 0 && + !connectionColumns.rows.some((column) => column["name"] === "oauth_client_owner") + ) { + await client.execute("ALTER TABLE connection ADD COLUMN oauth_client_owner TEXT"); + } if ( connectionColumns.rows.length > 0 && !connectionColumns.rows.some((column) => column["name"] === "identity_override") @@ -69,6 +75,20 @@ export const createSqliteFumaDb = async ( await client.execute("ALTER TABLE connection ADD COLUMN identity_override TEXT"); } + const oauthClientColumns = await client.execute("PRAGMA table_info('oauth_client')"); + if ( + oauthClientColumns.rows.length > 0 && + !oauthClientColumns.rows.some((column) => column["name"] === "origin_kind") + ) { + await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_kind TEXT"); + } + if ( + oauthClientColumns.rows.length > 0 && + !oauthClientColumns.rows.some((column) => column["name"] === "origin_integration") + ) { + await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_integration TEXT"); + } + const { db, fuma } = createExecutorFumaDb(drizzleDb, { tables: options.tables, namespace: options.namespace, diff --git a/apps/local/src/db/sqlite-import.test.ts b/apps/local/src/db/sqlite-import.test.ts deleted file mode 100644 index 1c8d575ee..000000000 --- a/apps/local/src/db/sqlite-import.test.ts +++ /dev/null @@ -1,600 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { type Client } from "@libsql/client"; -import { Schema } from "effect"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { collectTables } from "@executor-js/api/server"; -import { - boolColumn, - dateColumn, - jsonColumn, - nullableBigintColumn, - nullableTextColumn, - scopedExecutorTable, - textColumn, - type FumaTables, -} from "@executor-js/sdk"; -import { withQueryContext } from "fumadb/query"; - -import { openTestClient, openTestDb } from "../testing/libsql-test-db"; -import { importLegacySqliteIfNeeded, readBundledDrizzleMigrationHashes } from "../executor"; -import { importSqliteDataToFuma, readLegacySqliteScopeIds } from "./sqlite-import"; -import { createSqliteFumaDb, type SqliteFumaDb } from "./sqlite-fumadb"; - -let workDir: string; -let sqlite: SqliteFumaDb | null; -let heldReader: Client | null; - -beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), "executor-sqlite-import-")); - sqlite = null; - heldReader = null; -}); - -afterEach(async () => { - heldReader?.close(); - await sqlite?.close(); - rmSync(workDir, { recursive: true, force: true }); -}); - -const seedSqlite = async (path: string) => { - const db = openTestDb(path); - await db.exec(` - CREATE TABLE source ( - id TEXT PRIMARY KEY NOT NULL, - plugin_id TEXT NOT NULL, - kind TEXT NOT NULL, - name TEXT NOT NULL, - url TEXT, - can_remove INTEGER NOT NULL, - can_refresh INTEGER NOT NULL, - can_edit INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - CREATE TABLE blob ( - namespace TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - PRIMARY KEY (namespace, key) - ); - `); - await db - .prepare( - `INSERT INTO source ( - id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - "src_1", - "plugin", - "remote", - "Imported", - null, - 1, - 0, - 1, - 1_700_000_000_000, - 1_700_000_001_000, - ); - await db - .prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)") - .run("scope_a/plugin", "spec", "{}"); - db.close(); -}; - -const seedDrizzleMigrationHistory = async ( - db: ReturnType, - hashes: ReadonlyArray = readBundledDrizzleMigrationHashes( - join(import.meta.dirname, "../../drizzle"), - ), -) => { - await db.exec(` - CREATE TABLE "__drizzle_migrations" ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - hash text NOT NULL, - created_at numeric - ); - `); - const insert = await db.prepare( - `INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)`, - ); - for (const hash of hashes) { - await insert.run(hash, Date.now()); - } -}; - -const seedMigratedSqlite = async ( - path: string, - options?: { - readonly migrationHashes?: ReadonlyArray; - }, -) => { - const db = openTestDb(path); - await db.exec(` - CREATE TABLE source ( - scope_id TEXT NOT NULL, - id TEXT NOT NULL, - plugin_id TEXT NOT NULL, - kind TEXT NOT NULL, - name TEXT NOT NULL, - url TEXT, - can_remove INTEGER NOT NULL, - can_refresh INTEGER NOT NULL, - can_edit INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - CREATE TABLE blob ( - namespace TEXT NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL, - PRIMARY KEY (namespace, key) - ); - `); - await seedDrizzleMigrationHistory(db, options?.migrationHashes); - await db - .prepare( - `INSERT INTO source ( - scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - "scope_a", - "src_1", - "plugin", - "remote", - "Imported", - null, - 1, - 0, - 1, - 1_700_000_000_000, - 1_700_000_001_000, - ); - await db - .prepare("INSERT INTO blob (namespace, key, value) VALUES (?, ?, ?)") - .run("scope_a/plugin", "spec", "{}"); - db.close(); -}; - -const lateSchema = { - late_item: scopedExecutorTable("late_item", { - value: textColumn("value"), - }), -}; - -const ImportMarkerForTest = Schema.Struct({ - importedTables: Schema.Array(Schema.String), -}); -const decodeImportMarkerForTest = Schema.decodeUnknownSync( - Schema.fromJsonString(ImportMarkerForTest), -); - -const legacyShapeSchema = { - legacy_shape: scopedExecutorTable("legacy_shape", { - payload: jsonColumn("payload"), - enabled: boolColumn("enabled", false), - retry_after_ms: nullableBigintColumn("retry_after_ms"), - discovered_at: dateColumn("discovered_at"), - note: nullableTextColumn("note"), - }), -}; - -describe("importSqliteDataToFuma", () => { - it("imports current SQLite rows into FumaDB SQLite without replacing source files", async () => { - const sqlitePath = join(workDir, "data.db"); - const markerPath = join(workDir, "fumadb-sqlite-imported"); - await seedSqlite(sqlitePath); - - const tables = collectTables(); - sqlite = await createSqliteFumaDb({ - tables, - namespace: "executor_local_test", - path: join(workDir, "target.db"), - }); - - const scopedDb = withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }); - const result = await importSqliteDataToFuma({ - sqlitePath, - target: scopedDb, - tables, - scopeId: "scope_a", - }); - - expect(result.imported).toBe(true); - expect(result.importedRows).toBe(2); - expect(result.importedTables).toEqual(["source", "blob"]); - expect(existsSync(markerPath)).toBe(false); - expect(existsSync(sqlitePath)).toBe(true); - expect(result.backupPath).toBeUndefined(); - - const source = (await scopedDb.findFirst("source", { - where: (b) => b("id", "=", "src_1"), - })) as Record; - expect(source.scope_id).toBe("scope_a"); - expect(source.can_remove).toBe(true); - expect(source.can_refresh).toBe(false); - expect(source.can_edit).toBe(true); - expect(source.created_at).toBeInstanceOf(Date); - - const blob = (await scopedDb.findFirst("blob", { - where: (b) => b("id", "=", JSON.stringify(["scope_a/plugin", "spec"])), - })) as Record; - expect(blob.value).toBe("{}"); - }); - - it("imports every existing legacy scope from the global local database", async () => { - const sqlitePath = join(workDir, "data.db"); - const db = openTestDb(sqlitePath); - await db.exec(` - CREATE TABLE source ( - scope_id TEXT NOT NULL, - id TEXT NOT NULL, - plugin_id TEXT NOT NULL, - kind TEXT NOT NULL, - name TEXT NOT NULL, - url TEXT, - can_remove INTEGER NOT NULL, - can_refresh INTEGER NOT NULL, - can_edit INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - `); - const insert = await db.prepare( - `INSERT INTO source ( - scope_id, id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ); - await insert.run( - "scope_a", - "src_a", - "plugin", - "remote", - "Scope A", - null, - 1, - 0, - 1, - 1_700_000_000_000, - 1_700_000_001_000, - ); - await insert.run( - "scope_b", - "src_b", - "plugin", - "remote", - "Scope B", - null, - 1, - 0, - 1, - 1_700_000_000_000, - 1_700_000_001_000, - ); - db.close(); - - const tables = collectTables(); - const legacyScopeIds = await readLegacySqliteScopeIds({ - sqlitePath, - tables, - scopeId: "scope_a", - }); - expect([...legacyScopeIds].sort()).toEqual(["scope_a", "scope_b"]); - - sqlite = await createSqliteFumaDb({ - tables, - namespace: "executor_local_test", - path: join(workDir, "target.db"), - }); - await importSqliteDataToFuma({ - sqlitePath, - target: withQueryContext(sqlite.db, { allowedScopeIds: legacyScopeIds }), - tables, - scopeId: "scope_a", - }); - - await expect( - withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findMany("source", { - select: ["id", "scope_id", "name"], - orderBy: ["id", "asc"], - }), - ).resolves.toEqual([{ id: "src_a", scope_id: "scope_a", name: "Scope A" }]); - await expect( - withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_b"]) }).findMany("source", { - select: ["id", "scope_id", "name"], - orderBy: ["id", "asc"], - }), - ).resolves.toEqual([{ id: "src_b", scope_id: "scope_b", name: "Scope B" }]); - }); - - it("normalizes plugin table values when importing legacy SQLite rows", async () => { - const sqlitePath = join(workDir, "data.db"); - const db = openTestDb(sqlitePath); - await db.exec(` - CREATE TABLE legacy_shape ( - scope_id TEXT NOT NULL, - id TEXT NOT NULL, - payload TEXT NOT NULL, - enabled INTEGER NOT NULL, - retry_after_ms TEXT, - discovered_at INTEGER NOT NULL, - note TEXT, - PRIMARY KEY (scope_id, id) - ); - `); - await db - .prepare( - `INSERT INTO legacy_shape ( - scope_id, id, payload, enabled, retry_after_ms, discovered_at, note - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - "scope_a", - "shape_1", - JSON.stringify({ auth: { type: "oauth2" }, paths: ["/v1/items"] }), - 1, - "9007199254740993", - 1_700_000_000_000, - null, - ); - db.close(); - - const tables: FumaTables = { - ...collectTables(), - ...legacyShapeSchema, - }; - sqlite = await createSqliteFumaDb({ - tables, - namespace: "executor_local_test", - path: join(workDir, "target.db"), - }); - - const scopedDb = withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }); - const result = await importSqliteDataToFuma({ - sqlitePath, - target: scopedDb, - tables, - scopeId: "scope_a", - }); - - expect(result.importedTables).toEqual(["legacy_shape"]); - expect(result.importedRows).toBe(1); - - const row = await scopedDb.findFirst("legacy_shape", { - where: (b) => b("id", "=", "shape_1"), - }); - expect(row).toMatchObject({ - id: "shape_1", - scope_id: "scope_a", - payload: { auth: { type: "oauth2" }, paths: ["/v1/items"] }, - enabled: true, - note: null, - }); - expect(row?.retry_after_ms).toBe(9_007_199_254_740_993n); - expect(row?.discovered_at).toEqual(new Date(1_700_000_000_000)); - }); - - it("writes the import marker only after the replacement database is in place", async () => { - const sqlitePath = join(workDir, "data.db"); - const markerPath = join(workDir, "fumadb-sqlite-imported"); - await seedMigratedSqlite(sqlitePath); - - const tables = collectTables(); - const result = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables, - scopeId: "scope_a", - }); - - expect(result.imported).toBe(true); - expect(existsSync(markerPath)).toBe(true); - expect(existsSync(sqlitePath)).toBe(true); - expect(result.backupPath && existsSync(result.backupPath)).toBe(true); - - sqlite = await createSqliteFumaDb({ - tables, - namespace: "executor_local", - path: sqlitePath, - }); - await expect( - withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findFirst("source", { - where: (b) => b("id", "=", "src_1"), - }), - ).resolves.toMatchObject({ id: "src_1", scope_id: "scope_a" }); - }); - - it("imports an existing legacy schema with divergent Drizzle migration history", async () => { - const sqlitePath = join(workDir, "data.db"); - const markerPath = join(workDir, "fumadb-sqlite-imported"); - await seedMigratedSqlite(sqlitePath, { - migrationHashes: ["different-branch-migration", "newer-branch-migration"], - }); - - const tables = collectTables(); - const result = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables, - scopeId: "scope_a", - }); - - expect(result.imported).toBe(true); - expect(result.importedRows).toBe(2); - expect(result.importedTables).toEqual(["source", "blob"]); - expect(existsSync(markerPath)).toBe(true); - - sqlite = await createSqliteFumaDb({ - tables, - namespace: "executor_local", - path: sqlitePath, - }); - await expect( - withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findFirst("source", { - where: (b) => b("id", "=", "src_1"), - }), - ).resolves.toMatchObject({ id: "src_1", scope_id: "scope_a" }); - }); - - it("imports a checkpointed legacy WAL database even when DELETE journal mode is busy", async () => { - const sqlitePath = join(workDir, "data.db"); - const markerPath = join(workDir, "fumadb-sqlite-imported"); - await seedMigratedSqlite(sqlitePath); - - const writer = openTestClient(sqlitePath); - await writer.execute("PRAGMA journal_mode = WAL"); - writer.close(); - - // Hold a concurrent read on a SEPARATE libSQL connection so the importer's - // WAL checkpoint must contend with an open reader (busy_timeout handling). - heldReader = openTestClient(sqlitePath); - await heldReader.execute("BEGIN"); - await heldReader.execute("SELECT * FROM source"); - - const tables = collectTables(); - const result = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables, - scopeId: "scope_a", - }); - - expect(result.imported).toBe(true); - expect(result.importedRows).toBe(2); - expect(existsSync(markerPath)).toBe(true); - - sqlite = await createSqliteFumaDb({ - tables, - namespace: "executor_local", - path: sqlitePath, - }); - await expect( - withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findFirst("source", { - where: (b) => b("id", "=", "src_1"), - }), - ).resolves.toMatchObject({ id: "src_1", scope_id: "scope_a" }); - }); - - it("imports newly-available tables from the original backup after the first cutover", async () => { - const sqlitePath = join(workDir, "data.db"); - const markerPath = join(workDir, "fumadb-sqlite-imported"); - await seedMigratedSqlite(sqlitePath); - - const legacy = openTestDb(sqlitePath); - await legacy.exec(` - CREATE TABLE late_item ( - scope_id TEXT NOT NULL, - id TEXT NOT NULL, - value TEXT NOT NULL, - PRIMARY KEY (scope_id, id) - ); - `); - legacy - .prepare("INSERT INTO late_item (scope_id, id, value) VALUES (?, ?, ?)") - .run("scope_a", "late_1", "from-backup"); - legacy.close(); - - const firstTables = collectTables(); - const firstResult = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables: firstTables, - scopeId: "scope_a", - }); - expect(firstResult.importedTables).not.toContain("late_item"); - - const allTables: FumaTables = { - ...collectTables(), - ...lateSchema, - }; - const secondResult = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables: allTables, - scopeId: "scope_a", - }); - - expect(secondResult.imported).toBe(true); - expect(secondResult.importedTables).toEqual(["late_item"]); - - sqlite = await createSqliteFumaDb({ - tables: allTables, - namespace: "executor_local", - path: sqlitePath, - }); - await expect( - withQueryContext(sqlite.db, { allowedScopeIds: new Set(["scope_a"]) }).findMany("late_item", { - select: ["id", "value"], - }), - ).resolves.toEqual([{ id: "late_1", value: "from-backup" }]); - }); - - it("marks newly-available empty tables so startup does not retry backup imports", async () => { - const sqlitePath = join(workDir, "data.db"); - const markerPath = join(workDir, "fumadb-sqlite-imported"); - await seedMigratedSqlite(sqlitePath); - - const firstResult = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables: collectTables(), - scopeId: "scope_a", - }); - expect(firstResult.importedTables).not.toContain("late_item"); - - const allTables: FumaTables = { - ...collectTables(), - ...lateSchema, - }; - const secondResult = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables: allTables, - scopeId: "scope_a", - }); - - expect(secondResult.imported).toBe(false); - expect(secondResult.importedRows).toBe(0); - expect(decodeImportMarkerForTest(readFileSync(markerPath, "utf8")).importedTables).toContain( - "late_item", - ); - - const thirdResult = await importLegacySqliteIfNeeded({ - storage: { - dataDir: workDir, - sqlitePath, - importMarkerPath: markerPath, - }, - tables: allTables, - scopeId: "scope_a", - }); - expect(thirdResult.imported).toBe(false); - }); -}); diff --git a/apps/local/src/db/sqlite-import.ts b/apps/local/src/db/sqlite-import.ts deleted file mode 100644 index 3f0dc0bd1..000000000 --- a/apps/local/src/db/sqlite-import.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { type Client } from "@libsql/client"; -import { Data } from "effect"; -import { existsSync } from "node:fs"; - -/* oxlint-disable executor/no-json-parse, executor/no-switch-statement, executor/no-try-catch-or-throw -- boundary: one-shot legacy SQLite importer normalizes unknown rows and wraps native sqlite failures */ - -import { type AnyColumn, type AnyTable, type FumaTables } from "@executor-js/sdk"; - -import { openLegacyLibsql, queryFirst, queryRows } from "./libsql"; - -type SqliteRow = Record; - -type ImportFumaDb = Readonly<{ - createMany: (table: string, rows: SqliteRow[]) => Promise; - transaction: (run: (db: ImportFumaDb) => Promise) => Promise; -}>; - -export class LocalSqliteImportError extends Data.TaggedError("LocalSqliteImportError")<{ - readonly message: string; - readonly sqlitePath: string; - readonly table?: string; - readonly cause: unknown; -}> {} - -export interface LocalSqliteImportOptions { - readonly sqlitePath: string; - readonly target: ImportFumaDb; - readonly tables: FumaTables; - readonly scopeId: string; -} - -export interface LocalSqliteImportResult { - readonly imported: boolean; - readonly importedRows: number; - readonly importedTables: readonly string[]; - readonly backupPath?: string; -} - -const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; -const sqliteStringLiteral = (value: string): string => `'${value.replaceAll("'", "''")}'`; - -const tableExists = async (client: Client, tableName: string): Promise => { - const row = await queryFirst( - client, - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - [tableName], - ); - return row != null; -}; - -const sqliteColumnNames = async ( - client: Client, - tableName: string, -): Promise> => { - const rows = await queryRows<{ name: string }>( - client, - `PRAGMA table_info(${sqliteStringLiteral(tableName)})`, - ); - return new Set(rows.map((row) => row.name)); -}; - -const readRows = async (client: Client, tableName: string): Promise => - queryRows(client, `SELECT * FROM ${quoteIdent(tableName)}`); - -const readScopeIds = async (client: Client, tableName: string): Promise => - ( - await queryRows<{ scope_id: unknown }>( - client, - `SELECT DISTINCT "scope_id" AS scope_id FROM ${quoteIdent(tableName)} WHERE "scope_id" IS NOT NULL`, - ) - ).flatMap((row) => (typeof row.scope_id === "string" ? [row.scope_id] : [])); - -const parseJson = (value: string): unknown => { - try { - return JSON.parse(value); - } catch { - return value; - } -}; - -const toBigInt = (value: unknown): unknown => { - if (typeof value === "bigint") return value; - if (typeof value === "number" && Number.isFinite(value)) return BigInt(value); - if (typeof value === "string" && value.trim().length > 0) return BigInt(value); - return value; -}; - -const toDate = (value: unknown): unknown => { - if (value instanceof Date) return value; - if (typeof value === "number") return new Date(value); - if (typeof value === "string") { - const trimmed = value.trim(); - if (/^-?\d+$/.test(trimmed)) return new Date(Number(trimmed)); - return new Date(trimmed); - } - return value; -}; - -const toBool = (value: unknown): unknown => { - if (typeof value === "boolean") return value; - if (typeof value === "number") return value !== 0; - if (typeof value === "string") return value === "1" || value.toLowerCase() === "true"; - return value; -}; - -const defaultColumnValue = (input: { - readonly tableKey: string; - readonly columnKey: string; - readonly row: SqliteRow; - readonly scopeId: string; -}): unknown => { - if (input.columnKey === "scope_id") return input.scopeId; - if (input.tableKey === "blob" && input.columnKey === "id") { - const namespace = input.row.namespace; - const key = input.row.key; - if (typeof namespace === "string" && typeof key === "string") { - return JSON.stringify([namespace, key]); - } - } - return undefined; -}; - -const normalizeColumnValue = (value: unknown, column: AnyColumn): unknown => { - if (value === undefined || value === null) return value; - switch (column.type) { - case "bool": - return toBool(value); - case "bigint": - return toBigInt(value); - case "date": - case "timestamp": - return toDate(value); - case "json": - return typeof value === "string" ? parseJson(value) : value; - default: - return value; - } -}; - -const toFumaRow = (input: { - readonly tableKey: string; - readonly table: AnyTable; - readonly sqliteColumns: ReadonlySet; - readonly row: SqliteRow; - readonly scopeId: string; -}): SqliteRow => { - const out: SqliteRow = {}; - - for (const [columnKey, column] of Object.entries(input.table.columns)) { - if (columnKey === "row_id") continue; - - const sqlName = column.names.sql; - const rawValue = input.sqliteColumns.has(sqlName) - ? input.row[sqlName] - : defaultColumnValue({ - tableKey: input.tableKey, - columnKey, - row: input.row, - scopeId: input.scopeId, - }); - - const value = normalizeColumnValue(rawValue, column); - if (value !== undefined) out[columnKey] = value; - } - - return out; -}; - -export const readLegacySqliteScopeIds = async (options: { - readonly sqlitePath: string; - readonly tables: FumaTables; - readonly scopeId: string; -}): Promise> => { - const scopeIds = new Set([options.scopeId]); - if (!existsSync(options.sqlitePath)) return scopeIds; - - let client: Client | null = null; - try { - client = openLegacyLibsql(options.sqlitePath); - for (const table of Object.values(options.tables)) { - const tableName = table.names.sql; - if (!(await tableExists(client, tableName))) continue; - const columns = await sqliteColumnNames(client, tableName); - if (!columns.has("scope_id")) continue; - for (const scopeId of await readScopeIds(client, tableName)) { - scopeIds.add(scopeId); - } - } - return scopeIds; - } catch (cause) { - throw new LocalSqliteImportError({ - message: `Failed to inspect local SQLite scope ids from ${options.sqlitePath}`, - sqlitePath: options.sqlitePath, - cause, - }); - } finally { - client?.close(); - } -}; - -export const importSqliteDataToFuma = async ( - options: LocalSqliteImportOptions, -): Promise => { - if (!existsSync(options.sqlitePath)) { - return { imported: false, importedRows: 0, importedTables: [] }; - } - - let client: Client | null = null; - - try { - client = openLegacyLibsql(options.sqlitePath); - const reader = client; - const importedTables: string[] = []; - let importedRows = 0; - - await options.target.transaction(async (db) => { - for (const [tableKey, table] of Object.entries(options.tables)) { - const tableName = table.names.sql; - if (!(await tableExists(reader, tableName))) continue; - - const sqliteColumns = await sqliteColumnNames(reader, tableName); - const rows = (await readRows(reader, tableName)).map((row) => - toFumaRow({ - tableKey, - table, - sqliteColumns, - row, - scopeId: options.scopeId, - }), - ); - - if (rows.length === 0) continue; - await db.createMany(tableKey, rows); - importedTables.push(tableKey); - importedRows += rows.length; - } - }); - - client.close(); - client = null; - - return { imported: true, importedRows, importedTables }; - } catch (cause) { - throw new LocalSqliteImportError({ - message: `Failed to import local SQLite data from ${options.sqlitePath}`, - sqlitePath: options.sqlitePath, - cause, - }); - } finally { - client?.close(); - } -}; diff --git a/apps/local/src/db/v1-v2-migration.test.ts b/apps/local/src/db/v1-v2-migration.test.ts new file mode 100644 index 000000000..b83cb7dda --- /dev/null +++ b/apps/local/src/db/v1-v2-migration.test.ts @@ -0,0 +1,1189 @@ +import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; +import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import { Buffer } from "node:buffer"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Schema } from "effect"; + +import { collectTables } from "@executor-js/api/server"; +import { migratedItemId } from "@executor-js/sdk/migration"; + +import { executeSql, openLocalLibsql } from "./libsql"; +import { migrateLocalV1ToV2IfNeeded } from "./v1-v2-migration"; + +const AuthFile = Schema.Record(Schema.String, Schema.String); +const decodeAuthFile = Schema.decodeUnknownSync(Schema.fromJsonString(AuthFile)); +const decodeUnknownJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +// Preserves every journal field (`when`, `breakpoints`, …) — drizzle's +// migrator needs them, so only `entries` is typed for the truncation filter. +const decodeJournal = (text: string) => + decodeUnknownJson(text) as { + readonly entries: ReadonlyArray<{ readonly idx: number; readonly tag: string }>; + }; + +let workDir: string; +let previousXdgDataHome: string | undefined; +let previousFetch: typeof globalThis.fetch; + +beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "executor-local-v1-v2-")); + previousXdgDataHome = process.env.XDG_DATA_HOME; + previousFetch = globalThis.fetch; + process.env.XDG_DATA_HOME = join(workDir, "xdg"); +}); + +afterEach(() => { + if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previousXdgDataHome; + globalThis.fetch = previousFetch; + rmSync(workDir, { recursive: true, force: true }); +}); + +const seedV1Db = async ( + dbPath: string, + scopeId: string, + options: { + readonly includeSecretBackedOauth?: boolean; + readonly includeGraphqlTool?: boolean; + readonly includeMcpToolBinding?: boolean; + readonly includeMcpOauth?: boolean; + readonly jsonBlobs?: boolean; + readonly oauthConnectionProvider?: string; + readonly oauthProviderStateOverrides?: Record; + } = {}, +) => { + const client = await openLocalLibsql(dbPath); + await client.execute("PRAGMA foreign_keys = OFF"); + await client.execute(` + CREATE TABLE source ( + id text NOT NULL, + scope_id text NOT NULL, + plugin_id text NOT NULL, + kind text NOT NULL, + name text NOT NULL, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE plugin_storage ( + id text NOT NULL, + scope_id text NOT NULL, + plugin_id text NOT NULL, + collection text NOT NULL, + key text NOT NULL, + data text NOT NULL, + created_at integer NOT NULL, + updated_at integer NOT NULL, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE credential_binding ( + id text NOT NULL, + scope_id text NOT NULL, + plugin_id text NOT NULL, + source_id text NOT NULL, + source_scope_id text NOT NULL, + slot_key text NOT NULL, + kind text NOT NULL, + text_value text, + secret_id text, + connection_id text, + created_at integer NOT NULL, + updated_at integer NOT NULL, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE secret ( + id text NOT NULL, + scope_id text NOT NULL, + name text NOT NULL, + provider text NOT NULL, + owned_by_connection_id text, + created_at integer NOT NULL, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE connection ( + id text NOT NULL, + scope_id text NOT NULL, + provider text NOT NULL, + identity_label text, + access_token_secret_id text, + refresh_token_secret_id text, + expires_at integer, + provider_state text, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE tool_policy ( + id text NOT NULL, + scope_id text NOT NULL, + pattern text NOT NULL, + action text NOT NULL, + position text NOT NULL, + created_at integer NOT NULL, + updated_at integer NOT NULL, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE tool ( + id text NOT NULL, + scope_id text NOT NULL, + source_id text NOT NULL, + plugin_id text NOT NULL, + name text NOT NULL, + description text NOT NULL, + input_schema text, + output_schema text, + created_at integer NOT NULL, + updated_at integer NOT NULL, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE definition ( + id text NOT NULL, + scope_id text NOT NULL, + source_id text NOT NULL, + plugin_id text NOT NULL, + name text NOT NULL, + schema text NOT NULL, + created_at integer NOT NULL, + PRIMARY KEY(scope_id, id) + ) + `); + await client.execute(` + CREATE TABLE blob ( + namespace text NOT NULL, + key text NOT NULL, + value text NOT NULL, + row_id text NOT NULL, + id text NOT NULL, + PRIMARY KEY(id) + ) + `); + + const now = Date.now(); + const json = (value: unknown): string | Buffer => { + const text = JSON.stringify(value); + return options.jsonBlobs ? Buffer.from(text) : text; + }; + + await executeSql( + client, + "INSERT INTO source (id, scope_id, plugin_id, kind, name) VALUES (?, ?, ?, ?, ?)", + ["stripe_api", scopeId, "openapi", "openapi", "Stripe"], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "openapi-source-stripe", + scopeId, + "openapi", + "source", + "stripe_api", + json({ + config: { + spec: "{}", + headers: { + Authorization: { + kind: "binding", + slot: "header:authorization", + prefix: "Bearer ", + }, + }, + }, + }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "provider-settings", + scopeId, + "onepassword", + "settings", + "config", + json({ vaultId: "vault-123" }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO credential_binding (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "stripe-auth", + scopeId, + "openapi", + "stripe_api", + scopeId, + "header:authorization", + "secret", + null, + "stripe-key", + null, + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO secret (id, scope_id, name, provider, owned_by_connection_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ["stripe-key", scopeId, "Stripe key", "file", null, now], + ); + await executeSql( + client, + "INSERT INTO tool_policy (id, scope_id, pattern, action, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["policy-1", scopeId, "stripe_api.charges.create", "approve", "a0", now, now], + ); + await executeSql( + client, + "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "stripe_api.charges.create", + scopeId, + "stripe_api", + "openapi", + "charges.create", + "Create a charge", + json({ type: "object" }), + json({ type: "object" }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "openapi-operation-stripe-charge", + scopeId, + "openapi", + "operation", + "stripe_api.charges.create", + json({ + toolId: "stripe_api.charges.create", + sourceId: "stripe_api", + binding: { + method: "post", + pathTemplate: "/v1/charges", + parameters: [], + }, + }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO definition (id, scope_id, source_id, plugin_id, name, schema, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + [ + "stripe_api.Charge", + scopeId, + "stripe_api", + "openapi", + "Charge", + json({ type: "object", properties: { id: { type: "string" } } }), + now, + ], + ); + await executeSql( + client, + "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)", + [ + `${scopeId}/onepassword`, + "config", + JSON.stringify({ vaultId: "vault-123" }), + "blob-row", + JSON.stringify([`${scopeId}/onepassword`, "config"]), + ], + ); + + if (options.includeMcpToolBinding) { + await executeSql( + client, + "INSERT INTO source (id, scope_id, plugin_id, kind, name) VALUES (?, ?, ?, ?, ?)", + ["axiom_mcp", scopeId, "mcp", "mcp", "Axiom MCP"], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "mcp-source-axiom", + scopeId, + "mcp", + "source", + "axiom_mcp", + json({ + config: { + endpoint: "https://mcp.axiom.co/mcp", + headers: { + Authorization: { + kind: "binding", + slot: "header:authorization", + prefix: "Bearer ", + }, + }, + auth: { kind: "none" }, + }, + }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO secret (id, scope_id, name, provider, owned_by_connection_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ["axiom-token", scopeId, "Axiom MCP OAuth", "file", null, now], + ); + await executeSql( + client, + "INSERT INTO credential_binding (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "axiom-auth", + scopeId, + "mcp", + "axiom_mcp", + scopeId, + "header:authorization", + "secret", + null, + "axiom-token", + null, + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "axiom_mcp.querydataset", + scopeId, + "axiom_mcp", + "mcp", + "querydataset", + "Query Axiom datasets", + json({ type: "object" }), + json({ type: "object" }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "mcp-binding-axiom-querydataset", + scopeId, + "mcp", + "binding", + "axiom_mcp.querydataset", + json({ + namespace: "axiom_mcp", + toolId: "axiom_mcp.querydataset", + binding: { + toolId: "querydataset", + toolName: "queryDataset", + description: "Query Axiom datasets", + inputSchema: { type: "object" }, + annotations: { title: "Query dataset", readOnlyHint: true }, + }, + }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "graphql-operation-query-hello", + scopeId, + "graphql", + "operation", + "graphql_api.query.hello", + json({ + toolId: "graphql_api.query.hello", + sourceId: "graphql_api", + binding: { + kind: "query", + fieldName: "hello", + operationString: "query { hello }", + variableNames: [], + }, + }), + now, + now, + ], + ); + } + + if (options.includeGraphqlTool) { + await executeSql( + client, + "INSERT INTO source (id, scope_id, plugin_id, kind, name) VALUES (?, ?, ?, ?, ?)", + ["graphql_api", scopeId, "graphql", "graphql", "GraphQL API"], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "graphql-source-api", + scopeId, + "graphql", + "source", + "graphql_api", + json({ + config: { + endpoint: "https://api.example.com/graphql", + headers: { + Authorization: { + kind: "binding", + slot: "header:authorization", + prefix: "Bearer ", + }, + }, + auth: { kind: "none" }, + }, + }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO secret (id, scope_id, name, provider, owned_by_connection_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ["graphql-token", scopeId, "GraphQL token", "file", null, now], + ); + await executeSql( + client, + "INSERT INTO credential_binding (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "graphql-auth", + scopeId, + "graphql", + "graphql_api", + scopeId, + "header:authorization", + "secret", + null, + "graphql-token", + null, + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO tool (id, scope_id, source_id, plugin_id, name, description, input_schema, output_schema, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "graphql_api.query.hello", + scopeId, + "graphql_api", + "graphql", + "query.hello", + "GraphQL query", + json({ type: "object" }), + json({ type: "object" }), + now, + now, + ], + ); + } + + if (options.includeSecretBackedOauth) { + await executeSql( + client, + "INSERT INTO source (id, scope_id, plugin_id, kind, name) VALUES (?, ?, ?, ?, ?)", + ["dealcloud_api", scopeId, "openapi", "openapi", "DealCloud"], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "openapi-source-dealcloud", + scopeId, + "openapi", + "source", + "dealcloud_api", + json({ + config: { + spec: "{}", + oauth2: { + securitySchemeName: "dealCloudOAuth", + flow: "clientCredentials", + tokenUrl: "https://tenant.dealcloud.example/oauth/token", + scopes: ["data"], + }, + }, + }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO connection (id, scope_id, provider, identity_label, access_token_secret_id, refresh_token_secret_id, expires_at, provider_state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "dealcloud-oauth", + scopeId, + options.oauthConnectionProvider ?? "file", + "DealCloud API", + "dealcloud-access", + null, + null, + json({ + kind: "client-credentials", + clientIdSecretId: "dealcloud-client-id", + clientIdSecretScopeId: scopeId, + clientSecretSecretId: "dealcloud-client-secret", + clientSecretSecretScopeId: scopeId, + tokenEndpoint: "https://tenant.dealcloud.example/oauth/token", + resource: "https://api.dealcloud.com", + scopes: ["data"], + ...(options.oauthProviderStateOverrides ?? {}), + }), + ], + ); + await executeSql( + client, + "INSERT INTO credential_binding (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "dealcloud-auth", + scopeId, + "openapi", + "dealcloud_api", + scopeId, + "oauth2:dealcloudoauth:connection", + "connection", + null, + null, + "dealcloud-oauth", + now, + now, + ], + ); + for (const [id, name, owner] of [ + ["dealcloud-access", "DealCloud access token", "dealcloud-oauth"], + ["dealcloud-client-id", "DealCloud client id", null], + ["dealcloud-client-secret", "DealCloud client secret", null], + ] as const) { + await executeSql( + client, + "INSERT INTO secret (id, scope_id, name, provider, owned_by_connection_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + [id, scopeId, name, "file", owner, now], + ); + } + } + + if (options.includeMcpOauth) { + await executeSql( + client, + "INSERT INTO source (id, scope_id, plugin_id, kind, name) VALUES (?, ?, ?, ?, ?)", + ["pscale_mcp", scopeId, "mcp", "mcp", "PlanetScale MCP"], + ); + await executeSql( + client, + "INSERT INTO plugin_storage (id, scope_id, plugin_id, collection, key, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "mcp-source-pscale", + scopeId, + "mcp", + "source", + "pscale_mcp", + json({ + config: { + endpoint: "https://mcp.pscale.dev/mcp/planetscale", + transport: "remote", + auth: { kind: "oauth2", connectionSlot: "auth:oauth2:connection" }, + }, + }), + now, + now, + ], + ); + await executeSql( + client, + "INSERT INTO connection (id, scope_id, provider, identity_label, access_token_secret_id, refresh_token_secret_id, expires_at, provider_state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [ + "pscale-oauth", + scopeId, + options.oauthConnectionProvider ?? "oauth2", + "PlanetScale MCP OAuth", + "pscale-access", + "pscale-refresh", + now + 60_000, + json({ + kind: "authorization-code", + clientId: "pscale-client", + tokenEndpoint: "https://auth.pscale.dev/oauth/token", + authorizationServerUrl: "https://mcp.pscale.dev/oauth/authorize", + resource: "https://mcp.pscale.dev", + scopes: ["read"], + }), + ], + ); + await executeSql( + client, + "INSERT INTO credential_binding (id, scope_id, plugin_id, source_id, source_scope_id, slot_key, kind, text_value, secret_id, connection_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + "pscale-auth", + scopeId, + "mcp", + "pscale_mcp", + scopeId, + "auth:oauth2:connection", + "connection", + null, + null, + "pscale-oauth", + now, + now, + ], + ); + for (const [id, name] of [ + ["pscale-access", "PlanetScale access token"], + ["pscale-refresh", "PlanetScale refresh token"], + ] as const) { + await executeSql( + client, + "INSERT INTO secret (id, scope_id, name, provider, owned_by_connection_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + [id, scopeId, name, "file", "pscale-oauth", now], + ); + } + } + client.close(); +}; + +describe("local v1 -> v2 migration", () => { + it("moves a scoped v1 DB to a v2 DB and re-keys file auth.json", async () => { + const scopeId = "executor-workspace-abcd1234"; + const tenantId = "executor-workspace-abcd1234"; + const dataDir = join(workDir, "data"); + const dbPath = join(dataDir, "data.db"); + mkdirSync(dataDir, { recursive: true }); + await seedV1Db(dbPath, scopeId); + + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + join(authDir, "auth.json"), + JSON.stringify({ [scopeId]: { "stripe-key": "sk_test_123" } }, null, 2), + ); + + const result = await migrateLocalV1ToV2IfNeeded({ + sqlitePath: dbPath, + tables: collectTables(), + namespace: "executor_local", + tenantId, + }); + + expect(result.migrated).toBe(true); + expect(result.backupPath).toBeDefined(); + expect(result.report).toMatchObject({ integrations: 1, connections: 1, secretOps: 1 }); + + const client = await openLocalLibsql(dbPath); + const integrations = await client.execute("SELECT tenant, slug, plugin_id FROM integration"); + expect(integrations.rows).toEqual([ + { tenant: tenantId, slug: "stripe_api", plugin_id: "openapi" }, + ]); + + const connections = await client.execute( + "SELECT tenant, owner, subject, integration, name, provider, item_ids FROM connection", + ); + const itemId = migratedItemId(scopeId, "stripe-key"); + expect(connections.rows).toEqual([ + { + tenant: tenantId, + owner: "org", + subject: "", + integration: "stripe_api", + name: "stripeKey", + provider: "file", + item_ids: JSON.stringify({ token: itemId }), + }, + ]); + + const policies = await client.execute("SELECT pattern, action FROM tool_policy"); + expect(policies.rows).toEqual([ + { pattern: "stripe_api.*.*.charges.create", action: "approve" }, + ]); + + const tools = await client.execute( + "SELECT tenant, owner, subject, integration, connection, plugin_id, name, input_schema FROM tool", + ); + expect(tools.rows).toEqual([ + { + tenant: tenantId, + owner: "org", + subject: "", + integration: "stripe_api", + connection: "stripeKey", + plugin_id: "openapi", + name: "charges.create", + input_schema: JSON.stringify({ type: "object" }), + }, + ]); + + const definitions = await client.execute( + "SELECT tenant, owner, subject, integration, connection, plugin_id, name, schema FROM definition", + ); + expect(definitions.rows).toEqual([ + { + tenant: tenantId, + owner: "org", + subject: "", + integration: "stripe_api", + connection: "stripeKey", + plugin_id: "openapi", + name: "Charge", + schema: JSON.stringify({ type: "object", properties: { id: { type: "string" } } }), + }, + ]); + + const pluginStorage = await client.execute( + "SELECT tenant, owner, subject, plugin_id, collection, key, data FROM plugin_storage WHERE plugin_id = 'onepassword'", + ); + expect(pluginStorage.rows).toEqual([ + { + tenant: tenantId, + owner: "org", + subject: "", + plugin_id: "onepassword", + collection: "settings", + key: "config", + data: JSON.stringify({ vaultId: "vault-123" }), + }, + ]); + + const blobs = await client.execute("SELECT namespace, key, value FROM blob"); + expect(blobs.rows).toEqual([ + { + namespace: `o:${tenantId}/onepassword`, + key: "config", + value: JSON.stringify({ vaultId: "vault-123" }), + }, + ]); + client.close(); + + const auth = decodeAuthFile(readFileSync(join(authDir, "auth.json"), "utf-8")); + expect(auth[itemId]).toBe("sk_test_123"); + }); + + it("preserves migrated tool slugs and stamps MCP tools with their upstream binding", async () => { + const scopeId = "executor-workspace-abcd1234"; + const tenantId = "executor-workspace-abcd1234"; + const dataDir = join(workDir, "data"); + const dbPath = join(dataDir, "data.db"); + mkdirSync(dataDir, { recursive: true }); + await seedV1Db(dbPath, scopeId, { includeGraphqlTool: true, includeMcpToolBinding: true }); + + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + join(authDir, "auth.json"), + JSON.stringify( + { + [scopeId]: { + "stripe-key": "sk_test_123", + "axiom-token": "axiom-access-token", + "graphql-token": "graphql-access-token", + }, + }, + null, + 2, + ), + ); + + const result = await migrateLocalV1ToV2IfNeeded({ + sqlitePath: dbPath, + tables: collectTables(), + namespace: "executor_local", + tenantId, + }); + + expect(result.migrated).toBe(true); + + const client = await openLocalLibsql(dbPath); + const migratedToolNames = await client.execute( + "SELECT integration, plugin_id, name FROM tool ORDER BY integration, name", + ); + expect(migratedToolNames.rows).toEqual([ + { integration: "axiom_mcp", plugin_id: "mcp", name: "querydataset" }, + { integration: "graphql_api", plugin_id: "graphql", name: "query.hello" }, + { integration: "stripe_api", plugin_id: "openapi", name: "charges.create" }, + ]); + + const operationRows = await client.execute( + "SELECT tenant, owner, subject, plugin_id, collection, key, data FROM plugin_storage WHERE collection = 'operation' ORDER BY plugin_id, key", + ); + expect(operationRows.rows).toEqual([ + { + tenant: tenantId, + owner: "org", + subject: "", + plugin_id: "graphql", + collection: "operation", + key: "graphql_api.query.hello", + data: JSON.stringify({ + integration: "graphql_api", + toolName: "query.hello", + binding: { + kind: "query", + fieldName: "hello", + operationString: "query { hello }", + variableNames: [], + }, + }), + }, + { + tenant: tenantId, + owner: "org", + subject: "", + plugin_id: "openapi", + collection: "operation", + key: "stripe_api.charges.create", + data: JSON.stringify({ + integration: "stripe_api", + toolName: "charges.create", + binding: { + method: "post", + pathTemplate: "/v1/charges", + parameters: [], + }, + }), + }, + ]); + + const rows = await client.execute( + "SELECT connection, name, annotations FROM tool WHERE integration = 'axiom_mcp'", + ); + expect(rows.rows).toHaveLength(1); + expect(rows.rows[0]).toMatchObject({ + connection: "axiomMcpOauth", + name: "querydataset", + }); + + const annotations = decodeUnknownJson(String(rows.rows[0]!.annotations)); + expect(annotations).toMatchObject({ + requiresApproval: false, + mcp: { + toolName: "queryDataset", + upstream: { + title: "Query dataset", + readOnlyHint: true, + }, + }, + }); + client.close(); + }); + + it("resolves secret-backed v1 OAuth client ids into v2 oauth_client rows", async () => { + const scopeId = "executor-workspace-abcd1234"; + const tenantId = "executor-workspace-abcd1234"; + const dataDir = join(workDir, "data"); + const dbPath = join(dataDir, "data.db"); + mkdirSync(dataDir, { recursive: true }); + await seedV1Db(dbPath, scopeId, { + includeSecretBackedOauth: true, + jsonBlobs: true, + oauthConnectionProvider: "oauth2", + }); + + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + join(authDir, "auth.json"), + JSON.stringify( + { + [scopeId]: { + "stripe-key": "sk_test_123", + "dealcloud-access": "old-access-token", + "dealcloud-client-id": "dealcloud-client", + "dealcloud-client-secret": "dealcloud-secret", + }, + }, + null, + 2, + ), + ); + + const result = await migrateLocalV1ToV2IfNeeded({ + sqlitePath: dbPath, + tables: collectTables(), + namespace: "executor_local", + tenantId, + }); + + expect(result.migrated).toBe(true); + expect(result.report).toMatchObject({ + integrations: 2, + connections: 2, + oauthClients: 1, + secretOps: 3, + }); + + const client = await openLocalLibsql(dbPath); + const oauthClients = await client.execute( + "SELECT slug, grant, client_id, client_secret_item_id, token_url, authorization_url, resource FROM oauth_client", + ); + const clientSecretItemId = migratedItemId(scopeId, "dealcloud-client-secret"); + expect(oauthClients.rows).toEqual([ + { + slug: "dealcloud", + grant: "client_credentials", + client_id: "dealcloud-client", + client_secret_item_id: clientSecretItemId, + token_url: "https://tenant.dealcloud.example/oauth/token", + authorization_url: "", + resource: "https://api.dealcloud.com", + }, + ]); + + const connections = await client.execute( + "SELECT integration, name, template, provider, item_ids, oauth_client, oauth_client_owner, refresh_item_id, oauth_scope, expires_at FROM connection WHERE integration = 'dealcloud_api'", + ); + const accessItemId = migratedItemId(scopeId, "dealcloud-access"); + expect(connections.rows).toHaveLength(1); + expect(connections.rows[0]).toMatchObject({ + integration: "dealcloud_api", + name: "dealcloudApi", + template: "dealCloudOAuth", + provider: "file", + item_ids: JSON.stringify({ token: accessItemId }), + oauth_client: "dealcloud", + oauth_client_owner: "org", + refresh_item_id: null, + oauth_scope: "data", + }); + expect(Number(connections.rows[0]!.expires_at)).toBeGreaterThan(Date.now()); + client.close(); + + const auth = decodeAuthFile(readFileSync(join(authDir, "auth.json"), "utf-8")); + expect(auth[accessItemId]).toBe("old-access-token"); + expect(auth[clientSecretItemId]).toBe("dealcloud-secret"); + expect(auth["dealcloud-client-id"]).toBeUndefined(); + }); + + it("resolves v1 OAuth authorization-server metadata URLs before writing oauth_client rows", async () => { + const scopeId = "executor-workspace-abcd1234"; + const tenantId = "executor-workspace-abcd1234"; + const metadataUrl = + "https://mcp.pscale.dev/.well-known/oauth-authorization-server/mcp/planetscale"; + const dataDir = join(workDir, "data"); + const dbPath = join(dataDir, "data.db"); + mkdirSync(dataDir, { recursive: true }); + await seedV1Db(dbPath, scopeId, { + includeSecretBackedOauth: true, + oauthConnectionProvider: "oauth2", + oauthProviderStateOverrides: { + kind: "dynamic-dcr", + authorizationServerUrl: "https://mcp.pscale.dev/mcp/planetscale", + authorizationServerMetadataUrl: metadataUrl, + resource: "https://mcp.pscale.dev/mcp/planetscale", + }, + }); + + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + join(authDir, "auth.json"), + JSON.stringify({ + [scopeId]: { + "dealcloud-access": "old-access-token", + "dealcloud-client-id": "dealcloud-client", + "dealcloud-client-secret": "dealcloud-secret", + }, + }), + ); + + const seenMetadataUrls: string[] = []; + const oauthMetadataFetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL) => { + seenMetadataUrls.push(String(input)); + return new Response( + JSON.stringify({ + issuer: "https://api.planetscale.com", + authorization_endpoint: "https://app.planetscale.com/oauth/authorize", + token_endpoint: "https://auth.planetscale.com/oauth/token", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + { preconnect: globalThis.fetch.preconnect }, + ); + + const result = await migrateLocalV1ToV2IfNeeded({ + sqlitePath: dbPath, + tables: collectTables(), + namespace: "executor_local", + tenantId, + oauthMetadataFetch, + }); + + expect(result.migrated).toBe(true); + expect(seenMetadataUrls).toEqual([metadataUrl]); + + const client = await openLocalLibsql(dbPath); + const oauthClients = await client.execute( + "SELECT slug, grant, authorization_url, resource FROM oauth_client", + ); + expect(oauthClients.rows).toEqual([ + { + slug: "dealcloud", + grant: "authorization_code", + authorization_url: "https://app.planetscale.com/oauth/authorize", + resource: "https://mcp.pscale.dev/mcp/planetscale", + }, + ]); + client.close(); + }); + + it("discovers MCP OAuth protected-resource metadata before writing oauth_client rows", async () => { + const scopeId = "executor-workspace-abcd1234"; + const tenantId = "executor-workspace-abcd1234"; + const dataDir = join(workDir, "data"); + const dbPath = join(dataDir, "data.db"); + mkdirSync(dataDir, { recursive: true }); + await seedV1Db(dbPath, scopeId, { includeMcpOauth: true }); + + const authDir = join(process.env.XDG_DATA_HOME!, "executor"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + join(authDir, "auth.json"), + JSON.stringify({ + [scopeId]: { + "stripe-key": "sk_test_123", + "pscale-access": "old-access-token", + "pscale-refresh": "old-refresh-token", + }, + }), + ); + + const seenResourceUrls: string[] = []; + globalThis.fetch = Object.assign( + async (input: RequestInfo | URL) => { + const url = String(input); + seenResourceUrls.push(url); + if (url.includes("/.well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ resource: "https://mcp.pscale.dev/mcp/planetscale" }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response("{}", { status: 404, headers: { "content-type": "application/json" } }); + }, + { preconnect: previousFetch.preconnect }, + ); + + const result = await migrateLocalV1ToV2IfNeeded({ + sqlitePath: dbPath, + tables: collectTables(), + namespace: "executor_local", + tenantId, + }); + + expect(result.migrated).toBe(true); + expect(seenResourceUrls).toContain( + "https://mcp.pscale.dev/.well-known/oauth-protected-resource/mcp/planetscale", + ); + + const client = await openLocalLibsql(dbPath); + const oauthClients = await client.execute( + "SELECT slug, authorization_url, token_url, resource FROM oauth_client WHERE slug = 'pscale'", + ); + expect(oauthClients.rows).toEqual([ + { + slug: "pscale", + authorization_url: "https://mcp.pscale.dev/oauth/authorize", + token_url: "https://auth.pscale.dev/oauth/token", + resource: "https://mcp.pscale.dev/mcp/planetscale", + }, + ]); + client.close(); + }); + + // Regression: a database last touched by a release OLDER than v1-final + // (pre-0011 — no `plugin_storage` table) must be replayed through the + // bundled legacy drizzle chain before the v1→v2 data migration reads it. + // Without the replay, migration crashed with "no such table: plugin_storage" + // on every fresh 1.5.0 install over old data. + it("replays the legacy drizzle chain for a v1 database that predates v1-final", async () => { + const scopeId = "executor-workspace-abcd1234"; + const dataDir = join(workDir, "data"); + const dbPath = join(dataDir, "data.db"); + mkdirSync(dataDir, { recursive: true }); + + // Build a REAL pre-0011 v1 database: apply the vendored legacy chain + // truncated after 0010, so drizzle records the genuine hashes and the + // schema has per-plugin source tables but no `plugin_storage`. + const legacyDir = join(import.meta.dirname, "../../drizzle-legacy-v1"); + const truncatedDir = join(workDir, "legacy-truncated"); + mkdirSync(join(truncatedDir, "meta"), { recursive: true }); + const journal = decodeJournal( + readFileSync(join(legacyDir, "meta", "_journal.json")).toString(), + ); + const kept = journal.entries.filter((entry) => entry.idx <= 10); + for (const entry of kept) { + writeFileSync( + join(truncatedDir, `${entry.tag}.sql`), + readFileSync(join(legacyDir, `${entry.tag}.sql`)), + ); + } + writeFileSync( + join(truncatedDir, "meta", "_journal.json"), + JSON.stringify({ ...journal, entries: kept }), + ); + + const seed = await openLocalLibsql(dbPath); + await migrate(drizzle({ client: seed }), { migrationsFolder: truncatedDir }); + const missing = await seed.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'plugin_storage'", + ); + expect(missing.rows).toEqual([]); // genuinely pre-0011 + const now = Date.now(); + await executeSql( + seed, + "INSERT INTO source (id, scope_id, plugin_id, kind, name, url, can_remove, can_refresh, can_edit, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, 1, 1, ?, ?)", + ["context7", scopeId, "mcp", "mcp", "Context7", "https://mcp.context7.com/mcp", now, now], + ); + await executeSql( + seed, + "INSERT INTO mcp_source (id, scope_id, name, config, created_at) VALUES (?, ?, ?, ?, ?)", + [ + "context7", + scopeId, + "Context7", + JSON.stringify({ transport: "remote", endpoint: "https://mcp.context7.com/mcp" }), + now, + ], + ); + seed.close(); + + const result = await migrateLocalV1ToV2IfNeeded({ + sqlitePath: dbPath, + tables: collectTables(), + namespace: "executor_local", + tenantId: scopeId, + }); + + expect(result.migrated).toBe(true); + const client = await openLocalLibsql(dbPath); + const integrations = await client.execute("SELECT tenant, slug, plugin_id FROM integration"); + expect(integrations.rows).toEqual([{ tenant: scopeId, slug: "context7", plugin_id: "mcp" }]); + client.close(); + }); +}); diff --git a/apps/local/src/db/v1-v2-migration.ts b/apps/local/src/db/v1-v2-migration.ts new file mode 100644 index 000000000..ff59fab71 --- /dev/null +++ b/apps/local/src/db/v1-v2-migration.ts @@ -0,0 +1,986 @@ +/* oxlint-disable executor/no-json-parse, executor/no-raw-fetch, executor/no-try-catch-or-throw -- boundary: one-shot local SQLite/auth-file migration normalizes legacy on-disk state */ + +import type { Client } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import { Effect } from "effect"; +import { createId } from "fumadb/cuid"; +import { createHash, randomBytes } from "node:crypto"; +import * as fs from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { TextDecoder } from "node:util"; + +import type { FumaTables } from "@executor-js/sdk"; +import { + buildV1RuntimeMetadataIndex, + migrateGraphqlSourceConfig, + migrateMcpSourceConfig, + migrateOpenApiSourceConfig, + migrateV1PluginStorageRuntimeRow, + migrateV1ToolAnnotations, + migrationOAuthAuthorizationUrlFor as authorizationUrlFor, + migrationOAuthClientPlanKey as oauthClientPlanKey, + migrationSourceKey, + parseScope, + planMigration, + resolveMigrationOAuthAuthorizationUrls, + type MigratedSourceConfig, + type MigrationInput, + type MigrationOAuthMetadataFetch, + type MigrationOwner, + type MigrationPlan, + type OwnerKeys, + type V1SourceRow, +} from "@executor-js/sdk/migration"; +import { makeKeychainProvider } from "@executor-js/plugin-keychain"; + +import { createSqliteFumaDb } from "./sqlite-fumadb"; +import embeddedLegacyMigrations from "./embedded-migrations.gen"; +import { executeSql, openLocalLibsql, queryFirst, queryRows } from "./libsql"; + +type Row = Record; + +interface V1ToolRow { + readonly scopeId: string; + readonly sourceId: string; + readonly pluginId: string; + readonly name: string; + readonly description: string; + readonly inputSchema: unknown; + readonly outputSchema: unknown; + readonly annotations: unknown; + readonly createdAt: number; + readonly updatedAt: number; +} + +interface V1DefinitionRow { + readonly scopeId: string; + readonly sourceId: string; + readonly pluginId: string; + readonly name: string; + readonly schema: unknown; + readonly createdAt: number; +} + +interface V1PluginStorageRow { + readonly scopeId: string; + readonly pluginId: string; + readonly collection: string; + readonly key: string; + readonly data: unknown; + readonly createdAt: number; + readonly updatedAt: number; +} + +interface V1BlobRow { + readonly namespace: string; + readonly key: string; + readonly value: string; +} + +interface LocalV1Snapshot { + readonly input: MigrationInput; + readonly tools: readonly V1ToolRow[]; + readonly definitions: readonly V1DefinitionRow[]; + readonly pluginStorage: readonly V1PluginStorageRow[]; + readonly blobs: readonly V1BlobRow[]; +} + +export interface LocalV1V2MigrationResult { + readonly migrated: boolean; + readonly backupPath?: string; + readonly report?: MigrationPlan["report"]; + readonly warnings: readonly string[]; +} + +export interface LocalV1V2MigrationOptions { + readonly sqlitePath: string; + readonly tables: FumaTables; + readonly namespace: string; + readonly tenantId: string; + readonly oauthMetadataFetch?: MigrationOAuthMetadataFetch; + readonly oauthMetadataTimeoutMs?: number; +} + +const FILE_PROVIDER = "file"; +const KEYCHAIN_PROVIDER = "keychain"; + +const fileSetSuffixes = ["", "-wal", "-shm"] as const; + +const quoteIdent = (value: string): string => `"${value.replaceAll('"', '""')}"`; + +const tableExists = async (client: Client, table: string): Promise => + (await queryFirst(client, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", [ + table, + ])) != null; + +const columnNames = async (client: Client, table: string): Promise> => + new Set( + (await queryRows<{ name: string }>(client, `PRAGMA table_info(${quoteIdent(table)})`)).map( + (row) => row.name, + ), + ); + +const optionalColumn = (columns: ReadonlySet, table: string, column: string): string => + columns.has(column) ? `${quoteIdent(table)}.${quoteIdent(column)}` : "NULL"; + +const isLocalV1Database = async (client: Client): Promise => { + if (!(await tableExists(client, "source"))) return false; + const sourceColumns = await columnNames(client, "source"); + if (!sourceColumns.has("scope_id")) return false; + if (!(await tableExists(client, "integration"))) return true; + const connectionColumns = await columnNames(client, "connection"); + return !connectionColumns.has("tenant") || connectionColumns.has("scope_id"); +}; + +// --------------------------------------------------------------------------- +// Legacy v1 schema replay. +// +// The v1→v2 data migration below reads the v1-FINAL schema (it queries +// `plugin_storage`, which only exists after v1 migration 0011). A database +// last touched by an older release is still mid-chain, so replay the bundled +// legacy drizzle migrations (`apps/local/drizzle-legacy-v1`, embedded into +// the binary by apps/cli/src/build.ts) to bring it to v1-final first — the +// same step every pre-v1.5 release performed at startup. +// --------------------------------------------------------------------------- + +const resolveLegacyMigrationsFolder = (): string => { + if (!embeddedLegacyMigrations) { + return join(import.meta.dirname, "../../drizzle-legacy-v1"); + } + // drizzle's migrate() reads a folder from disk; materialize the embedded + // contents into a tmpdir. + const dir = fs.mkdtempSync(join(tmpdir(), "executor-legacy-migrations-")); + for (const [rel, content] of Object.entries(embeddedLegacyMigrations)) { + const target = join(dir, rel); + fs.mkdirSync(dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + return dir; +}; + +const readBundledLegacyMigrationHashes = (migrationsFolder: string): readonly string[] => { + const journal = JSON.parse( + fs.readFileSync(join(migrationsFolder, "meta", "_journal.json")).toString(), + ) as { entries: ReadonlyArray<{ idx: number; tag: string }> }; + return [...journal.entries] + .sort((left, right) => left.idx - right.idx) + .map((entry) => + createHash("sha256") + .update(fs.readFileSync(join(migrationsFolder, `${entry.tag}.sql`)).toString()) + .digest("hex"), + ); +}; + +const readAppliedLegacyMigrationHashes = async (client: Client): Promise => + ( + await queryRows<{ hash: string }>( + client, + "SELECT hash FROM __drizzle_migrations ORDER BY id ASC", + ) + ).map((row) => row.hash); + +/** Bring a v1 database that predates v1-final up to the last v1 schema. Only + * replays when the database's drizzle history is a strict prefix of the + * bundled legacy chain — anything else is left as-is with a warning (the + * data migration may still succeed if the schema is close enough). */ +const replayLegacyV1Migrations = async (client: Client, warnings: string[]): Promise => { + if (!(await tableExists(client, "__drizzle_migrations"))) { + warnings.push( + "v1 database has no drizzle migration history; skipping the legacy schema replay.", + ); + return; + } + const migrationsFolder = resolveLegacyMigrationsFolder(); + const bundled = readBundledLegacyMigrationHashes(migrationsFolder); + const applied = await readAppliedLegacyMigrationHashes(client); + const isPrefix = + applied.length <= bundled.length && applied.every((hash, index) => hash === bundled[index]); + if (!isPrefix) { + warnings.push( + "v1 database migration history does not match this build's bundled legacy migrations; reading the schema as-is.", + ); + return; + } + if (applied.length === bundled.length) return; // already v1-final + await migrate(drizzle({ client }), { migrationsFolder }); +}; + +const textDecoder = new TextDecoder(); + +const decodeBytes = (value: ArrayBuffer | ArrayBufferView): string => { + const bytes = + value instanceof ArrayBuffer + ? new Uint8Array(value) + : new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + return textDecoder.decode(bytes); +}; + +const parseJson = (value: unknown): unknown => { + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + return parseJson(decodeBytes(value)); + } + if (typeof value !== "string") return value; + if (value.trim() === "") return null; + return JSON.parse(value); +}; + +const stringOrNull = (value: unknown): string | null => (value == null ? null : String(value)); + +const numberOrNull = (value: unknown): number | null => { + if (value == null) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +}; + +const numberOrDefault = (value: unknown, fallback: number): number => + numberOrNull(value) ?? fallback; + +const normalizePluginId = (pluginId: string, kind: string): string => + pluginId === "graphql-greenfield" ? "graphql" : pluginId || kind; + +const buildConfig = (kind: string, data: Record): MigratedSourceConfig => { + const cfg = (data.config as Record | undefined) ?? data; + if (kind === "mcp") return migrateMcpSourceConfig(cfg as never); + if (kind === "graphql") return migrateGraphqlSourceConfig(cfg as never); + return migrateOpenApiSourceConfig(cfg as never); +}; + +const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const sourceKeyForBinding = (binding: Row): string => + migrationSourceKey( + binding.source_scope_id == null ? String(binding.scope_id) : String(binding.source_scope_id), + String(binding.source_id), + ); + +const mcpOAuthEndpoint = (config: MigratedSourceConfig | undefined): string | null => { + const value = config?.config; + if (!isObjectRecord(value)) return null; + if (typeof value.endpoint !== "string" || value.endpoint.length === 0) return null; + if (!isObjectRecord(value.auth) || value.auth.kind !== "oauth2") return null; + return value.endpoint; +}; + +const canonicalResource = (value: string): string | null => { + try { + const url = new URL(value); + return `${url.protocol.toLowerCase()}//${url.host.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; + } catch { + return null; + } +}; + +const resourceMatchesEndpoint = (resource: string, endpoint: string): boolean => { + const actual = canonicalResource(resource); + const expected = canonicalResource(endpoint); + return ( + actual != null && expected != null && (actual === expected || expected.startsWith(`${actual}/`)) + ); +}; + +const protectedResourceMetadataUrls = (endpoint: string): readonly string[] => { + try { + const url = new URL(endpoint); + const origin = url.origin; + const path = url.pathname.replace(/\/+$/, ""); + const urls: string[] = []; + if (path && path !== "/") urls.push(`${origin}/.well-known/oauth-protected-resource${path}`); + urls.push(`${origin}/.well-known/oauth-protected-resource`); + return [...new Set(urls)]; + } catch { + return []; + } +}; + +const discoverProtectedResource = async (endpoint: string): Promise => { + for (const url of protectedResourceMetadataUrls(endpoint)) { + try { + const response = await fetch(url, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok) continue; + const json = (await response.json()) as unknown; + if (!isObjectRecord(json) || typeof json.resource !== "string") continue; + if (resourceMatchesEndpoint(json.resource, endpoint)) return json.resource; + } catch { + continue; + } + } + return null; +}; + +const discoverMcpOAuthResourceOverrides = async ( + bindings: readonly Row[], + migratedConfigs: ReadonlyMap, +): Promise> => { + const endpointByKey = new Map(); + for (const binding of bindings) { + if (binding.kind !== "connection") continue; + const key = sourceKeyForBinding(binding); + const endpoint = mcpOAuthEndpoint(migratedConfigs.get(key)); + if (endpoint) endpointByKey.set(key, endpoint); + } + const resourceByEndpoint = new Map(); + await Promise.all( + [...new Set(endpointByKey.values())].map(async (endpoint) => { + resourceByEndpoint.set(endpoint, await discoverProtectedResource(endpoint)); + }), + ); + const overrides = new Map(); + for (const [key, endpoint] of endpointByKey) { + const resource = resourceByEndpoint.get(endpoint); + if (resource) overrides.set(key, resource); + } + return overrides; +}; + +const localOwnerForScope = + (tenantId: string) => + (scopeId: string): OwnerKeys | null => { + const cloud = parseScope(scopeId); + if (cloud) return cloud; + return { owner: "org", subject: "", tenant: tenantId }; + }; + +const readV1Snapshot = async (client: Client, tenantId: string): Promise => { + const hasDefinition = await tableExists(client, "definition"); + const hasBlob = await tableExists(client, "blob"); + const bindingColumns = await columnNames(client, "credential_binding"); + const toolColumns = await columnNames(client, "tool"); + const definitionColumns = hasDefinition + ? await columnNames(client, "definition") + : new Set(); + + const [ + sources, + secrets, + bindings, + connections, + policies, + sourceStorage, + allPluginStorage, + toolSources, + tools, + definitions, + blobs, + ] = await Promise.all([ + queryRows(client, "SELECT scope_id, id, plugin_id, kind, name FROM source"), + queryRows( + client, + "SELECT id, scope_id, name, provider, owned_by_connection_id FROM secret", + ), + queryRows( + client, + `SELECT scope_id, ${optionalColumn(bindingColumns, "credential_binding", "source_scope_id")} AS source_scope_id, source_id, slot_key, kind, secret_id, ${optionalColumn(bindingColumns, "credential_binding", "secret_scope_id")} AS secret_scope_id, connection_id, text_value FROM credential_binding`, + ), + queryRows( + client, + "SELECT id, scope_id, provider, identity_label, access_token_secret_id, refresh_token_secret_id, expires_at, provider_state FROM connection", + ), + queryRows(client, "SELECT id, scope_id, pattern, action, position FROM tool_policy"), + queryRows( + client, + "SELECT ps.scope_id, ps.key AS source_id, ps.data, s.kind FROM plugin_storage ps JOIN source s ON ps.key = s.id AND ps.scope_id = s.scope_id WHERE ps.collection = 'source'", + ), + queryRows( + client, + "SELECT scope_id, plugin_id, collection, key, data, created_at, updated_at FROM plugin_storage", + ), + queryRows(client, "SELECT DISTINCT source_id FROM tool"), + queryRows( + client, + `SELECT scope_id, source_id, plugin_id, name, description, ${optionalColumn(toolColumns, "tool", "input_schema")} AS input_schema, ${optionalColumn(toolColumns, "tool", "output_schema")} AS output_schema, ${optionalColumn(toolColumns, "tool", "annotations")} AS annotations, created_at, updated_at FROM tool`, + ), + hasDefinition + ? queryRows( + client, + `SELECT scope_id, source_id, plugin_id, name, ${optionalColumn(definitionColumns, "definition", "schema")} AS schema, created_at FROM definition`, + ) + : Promise.resolve([]), + hasBlob + ? queryRows(client, "SELECT namespace, key, value FROM blob") + : Promise.resolve([]), + ]); + + const migratedConfigs = new Map(); + for (const row of sourceStorage) { + const data = parseJson(row.data) as Record; + migratedConfigs.set( + migrationSourceKey(String(row.scope_id), String(row.source_id)), + buildConfig(String(row.kind), data), + ); + } + const oauthResourceOverrides = await discoverMcpOAuthResourceOverrides(bindings, migratedConfigs); + + return { + input: { + nowMs: Date.now(), + ownerForScope: localOwnerForScope(tenantId), + defaultWritableProvider: FILE_PROVIDER, + sources: sources.map( + (source): V1SourceRow => ({ + scopeId: String(source.scope_id), + id: String(source.id), + pluginId: normalizePluginId(String(source.plugin_id), String(source.kind)), + name: source.name == null ? String(source.id) : String(source.name), + }), + ), + migratedConfigs, + oauthResourceOverrides, + connections: connections.map((connection) => ({ + id: String(connection.id), + scopeId: String(connection.scope_id), + provider: String(connection.provider), + identityLabel: stringOrNull(connection.identity_label), + accessTokenSecretId: stringOrNull(connection.access_token_secret_id), + refreshTokenSecretId: stringOrNull(connection.refresh_token_secret_id), + expiresAt: numberOrNull(connection.expires_at), + providerState: (parseJson(connection.provider_state) as never) ?? null, + })), + bindings: bindings.map((binding) => ({ + scopeId: String(binding.scope_id), + sourceScopeId: + binding.source_scope_id == null ? undefined : String(binding.source_scope_id), + sourceId: String(binding.source_id), + slotKey: String(binding.slot_key), + kind: binding.kind as "secret" | "connection" | "text", + secretId: stringOrNull(binding.secret_id), + secretScopeId: stringOrNull(binding.secret_scope_id), + connectionId: stringOrNull(binding.connection_id), + textValue: stringOrNull(binding.text_value), + })), + secrets: secrets.map((secret) => ({ + id: String(secret.id), + scopeId: String(secret.scope_id), + name: String(secret.name), + provider: String(secret.provider), + ownedByConnectionId: stringOrNull(secret.owned_by_connection_id), + })), + policies: policies.map((policy) => ({ + id: String(policy.id), + scopeId: String(policy.scope_id), + pattern: String(policy.pattern), + action: String(policy.action), + position: String(policy.position), + })), + toolSourceIds: toolSources.map((tool) => String(tool.source_id)), + }, + tools: tools.map((tool) => ({ + scopeId: String(tool.scope_id), + sourceId: String(tool.source_id), + pluginId: normalizePluginId(String(tool.plugin_id), ""), + name: String(tool.name), + description: String(tool.description), + inputSchema: parseJson(tool.input_schema), + outputSchema: parseJson(tool.output_schema), + annotations: parseJson(tool.annotations), + createdAt: numberOrDefault(tool.created_at, Date.now()), + updatedAt: numberOrDefault(tool.updated_at, Date.now()), + })), + definitions: definitions.map((definition) => ({ + scopeId: String(definition.scope_id), + sourceId: String(definition.source_id), + pluginId: normalizePluginId(String(definition.plugin_id), ""), + name: String(definition.name), + schema: parseJson(definition.schema) ?? {}, + createdAt: numberOrDefault(definition.created_at, Date.now()), + })), + pluginStorage: allPluginStorage + .filter((row) => String(row.collection) !== "source") + .map((row) => ({ + scopeId: String(row.scope_id), + pluginId: normalizePluginId(String(row.plugin_id), ""), + collection: String(row.collection), + key: String(row.key), + data: parseJson(row.data), + createdAt: numberOrDefault(row.created_at, Date.now()), + updatedAt: numberOrDefault(row.updated_at, Date.now()), + })), + blobs: blobs.map((blob) => ({ + namespace: String(blob.namespace), + key: String(blob.key), + value: String(blob.value), + })), + }; +}; + +const resolveFileAuthPath = (): string => { + const xdg = + process.env.XDG_DATA_HOME?.trim() || + (process.platform === "win32" + ? process.env.LOCALAPPDATA || process.env.APPDATA || join(homedir(), "AppData", "Local") + : join(homedir(), ".local", "share")); + return join(xdg, "executor", "auth.json"); +}; + +type AuthFile = Record>; + +const readAuthFile = (path: string): AuthFile => { + if (!fs.existsSync(path)) return {}; + return JSON.parse(fs.readFileSync(path, "utf-8")) as AuthFile; +}; + +const readScopedFileSecret = (auth: AuthFile, scopeId: string, secretId: string): string | null => { + const scoped = auth[scopeId]; + if (scoped && typeof scoped === "object" && !Array.isArray(scoped)) { + return scoped[secretId] ?? null; + } + const flat = auth[secretId]; + return typeof flat === "string" ? flat : null; +}; + +const flatAuthEntries = (auth: AuthFile): Record => { + const out: Record = {}; + for (const [key, value] of Object.entries(auth)) { + if (typeof value === "string") out[key] = value; + } + return out; +}; + +const writeFlatAuthFile = (path: string, values: Record): void => { + if (Object.keys(values).length === 0) return; + if (fs.existsSync(path)) { + fs.copyFileSync(path, `${path}.v1-v2-${Date.now()}-${randomBytes(4).toString("hex")}`); + } + fs.mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tmp = `${path}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tmp, path); +}; + +const keychainBaseServiceName = (): string => + process.env.EXECUTOR_KEYCHAIN_SERVICE_NAME?.trim() || "executor"; + +const providerGet = async ( + provider: string, + scopeId: string, + secretId: string, +): Promise => { + if (provider === FILE_PROVIDER) { + return readScopedFileSecret(readAuthFile(resolveFileAuthPath()), scopeId, secretId); + } + if (provider === KEYCHAIN_PROVIDER) { + const oldProvider = makeKeychainProvider(`${keychainBaseServiceName()}/${scopeId}`); + return await Effect.runPromise(oldProvider.get(secretId as never)); + } + return null; +}; + +const collectSecretValues = async ( + plan: MigrationPlan, +): Promise<{ + readonly fileValues: Record; + readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>; + readonly idOverrides: ReadonlyMap; + readonly oauthClientIdValues: ReadonlyMap; + readonly warnings: readonly string[]; +}> => { + const authPath = resolveFileAuthPath(); + const fileValues = flatAuthEntries(readAuthFile(authPath)); + const keychainValues: { id: string; value: string }[] = []; + const idOverrides = new Map(); + const oauthClientIdValues = new Map(); + const warnings: string[] = []; + + for (const op of plan.secretOps) { + if (op.targetProvider !== FILE_PROVIDER && op.targetProvider !== KEYCHAIN_PROVIDER) { + if (op.fromSecret) idOverrides.set(op.itemId, op.fromSecret.secretId); + continue; + } + + const value = + op.fromText ?? + (op.fromSecret + ? await providerGet(op.fromSecret.provider, op.fromSecret.scopeId, op.fromSecret.secretId) + : null); + if (value == null) { + warnings.push( + `Could not resolve local secret "${op.fromSecret?.secretId ?? op.itemId}" from provider "${op.fromSecret?.provider ?? op.targetProvider}".`, + ); + continue; + } + + if (op.targetProvider === FILE_PROVIDER) { + fileValues[op.itemId] = value; + } else { + keychainValues.push({ id: op.itemId, value }); + } + } + + for (const client of plan.oauthClients) { + if (client.clientId.length > 0 || !client.clientIdSecretRef) continue; + const value = await providerGet( + client.clientIdSecretRef.provider, + client.clientIdSecretRef.scopeId, + client.clientIdSecretRef.secretId, + ); + if (value == null) { + warnings.push( + `Could not resolve OAuth client id "${client.clientIdSecretRef.secretId}" from provider "${client.clientIdSecretRef.provider}".`, + ); + continue; + } + oauthClientIdValues.set(oauthClientPlanKey(client), value); + } + + return { fileValues, keychainValues, idOverrides, oauthClientIdValues, warnings }; +}; + +const mapId = (id: string | null, overrides: ReadonlyMap): string | null => + id == null ? null : (overrides.get(id) ?? id); + +const mapItemIds = ( + ids: Record, + overrides: ReadonlyMap, +): Record => + Object.fromEntries(Object.entries(ids).map(([key, id]) => [key, overrides.get(id) ?? id])); + +const timestamp = (): number => Date.now(); + +const ownerSubject = (owner: MigrationOwner, subject: string): string => + owner === "org" ? "" : subject; + +const clientIdFor = ( + client: MigrationPlan["oauthClients"][number], + values: ReadonlyMap, +): string => client.clientId || values.get(oauthClientPlanKey(client)) || ""; + +const jsonText = (value: unknown): string | null => { + if (value == null) return null; + return typeof value === "string" ? value : JSON.stringify(value); +}; + +const requiredJsonText = (value: unknown): string => jsonText(value) ?? JSON.stringify({}); + +const sqliteBigintText = (value: number | null): string | null => + value == null ? null : String(Math.trunc(value)); + +const legacyBlobNamespace = ( + namespace: string, +): { readonly scopeId: string; readonly pluginId: string } | null => { + const slash = namespace.indexOf("/"); + if (slash <= 0 || slash === namespace.length - 1) return null; + return { scopeId: namespace.slice(0, slash), pluginId: namespace.slice(slash + 1) }; +}; + +const v2BlobNamespace = (owner: OwnerKeys, pluginId: string): string => { + const partition = + owner.owner === "org" ? `o:${owner.tenant}` : `u:${owner.tenant}:${owner.subject}`; + return `${partition}/${pluginId}`; +}; + +const insertPlan = async ( + client: Client, + snapshot: LocalV1Snapshot, + plan: MigrationPlan, + idOverrides: ReadonlyMap, + oauthClientIdValues: ReadonlyMap, + oauthAuthorizationUrls: ReadonlyMap, + tenantId: string, +): Promise => { + const now = timestamp(); + const ownerForScope = localOwnerForScope(tenantId); + const connectionTargets = plan.connections.map((connection) => ({ + sourceScopeId: connection.sourceScopeId, + sourceId: connection.sourceId, + tenant: connection.row.tenant, + owner: connection.row.owner, + subject: ownerSubject(connection.row.owner, connection.row.subject), + connection: connection.row.name, + })); + const runtimeMetadata = buildV1RuntimeMetadataIndex(snapshot.pluginStorage); + const targetsFor = (scopeId: string, sourceId: string) => + connectionTargets.filter( + (target) => target.sourceScopeId === scopeId && target.sourceId === sourceId, + ); + + await client.execute("BEGIN"); + try { + for (const row of plan.integrations) { + await executeSql( + client, + "INSERT INTO integration (slug, plugin_id, description, config, can_remove, can_refresh, created_at, updated_at, row_id, tenant) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + row.slug, + row.plugin_id, + row.description, + jsonText(row.config), + 1, + 0, + now, + now, + createId(), + row.tenant, + ], + ); + } + + for (const clientRow of plan.oauthClients) { + await executeSql( + client, + "INSERT INTO oauth_client (slug, authorization_url, token_url, grant, client_id, client_secret_item_id, resource, created_at, row_id, tenant, owner, subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + clientRow.slug, + authorizationUrlFor(clientRow, oauthAuthorizationUrls), + clientRow.tokenUrl, + clientRow.grant, + clientIdFor(clientRow, oauthClientIdValues), + mapId(clientRow.clientSecretItemId, idOverrides), + clientRow.resource, + now, + createId(), + clientRow.ownerKeys.tenant, + clientRow.ownerKeys.owner, + ownerSubject(clientRow.ownerKeys.owner, clientRow.ownerKeys.subject), + ], + ); + } + + for (const connection of plan.connections) { + const row = connection.row; + await executeSql( + client, + "INSERT INTO connection (integration, name, template, provider, item_ids, identity_label, oauth_client, oauth_client_owner, refresh_item_id, expires_at, oauth_scope, provider_state, created_at, updated_at, row_id, tenant, owner, subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + row.integration, + row.name, + row.template, + row.provider, + JSON.stringify(mapItemIds(connection.itemIds, idOverrides)), + row.identityLabel, + row.oauthClientSlug, + row.oauthClientOwner, + mapId(connection.refreshItemId, idOverrides), + sqliteBigintText(row.expiresAt), + row.oauthScope, + null, + now, + now, + createId(), + row.tenant, + row.owner, + ownerSubject(row.owner, row.subject), + ], + ); + } + + for (const row of snapshot.tools) { + for (const target of targetsFor(row.scopeId, row.sourceId)) { + await executeSql( + client, + "INSERT INTO tool (integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id, tenant, owner, subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + row.sourceId, + target.connection, + row.pluginId, + row.name, + row.description, + jsonText(row.inputSchema), + jsonText(row.outputSchema), + jsonText(migrateV1ToolAnnotations(row, runtimeMetadata)), + row.createdAt, + row.updatedAt, + createId(), + target.tenant, + target.owner, + target.subject, + ], + ); + } + } + + for (const row of snapshot.definitions) { + for (const target of targetsFor(row.scopeId, row.sourceId)) { + await executeSql( + client, + "INSERT INTO definition (integration, connection, plugin_id, name, schema, created_at, row_id, tenant, owner, subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + row.sourceId, + target.connection, + row.pluginId, + row.name, + requiredJsonText(row.schema), + row.createdAt, + createId(), + target.tenant, + target.owner, + target.subject, + ], + ); + } + } + + for (const row of snapshot.pluginStorage) { + const migrated = migrateV1PluginStorageRuntimeRow(row); + const baseOwner = ownerForScope(row.scopeId); + const owner = + baseOwner && migrated.owner === "catalog" + ? { ...baseOwner, owner: "org" as const, subject: "" } + : baseOwner; + if (!owner) continue; + await executeSql( + client, + "INSERT INTO plugin_storage (plugin_id, collection, key, data, created_at, updated_at, row_id, tenant, owner, subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + migrated.pluginId, + migrated.collection, + migrated.key, + requiredJsonText(migrated.data), + row.createdAt, + row.updatedAt, + createId(), + owner.tenant, + owner.owner, + ownerSubject(owner.owner, owner.subject), + ], + ); + } + + for (const row of snapshot.blobs) { + const parsed = legacyBlobNamespace(row.namespace); + if (!parsed) continue; + const owner = ownerForScope(parsed.scopeId); + if (!owner) continue; + const namespace = v2BlobNamespace(owner, parsed.pluginId); + await executeSql( + client, + "INSERT INTO blob (namespace, key, value, row_id, id) VALUES (?, ?, ?, ?, ?)", + [namespace, row.key, row.value, createId(), JSON.stringify([namespace, row.key])], + ); + } + + for (const policy of plan.policies) { + await executeSql( + client, + "INSERT INTO tool_policy (id, pattern, action, position, created_at, updated_at, row_id, tenant, owner, subject) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + policy.id, + policy.pattern, + policy.action, + policy.position, + now, + now, + createId(), + policy.owner.tenant, + policy.owner.owner, + ownerSubject(policy.owner.owner, policy.owner.subject), + ], + ); + } + + await client.execute("COMMIT"); + } catch (cause) { + await client.execute("ROLLBACK"); + throw cause; + } +}; + +// Windows reports EBUSY/EPERM on rename for a short window after a SQLite +// handle closes (handle release lags the close call, and antivirus scanners +// briefly lock the file). POSIX renames never hit this — retry with a short +// backoff instead of failing the whole migration. +const renameWithRetry = async (source: string, target: string): Promise => { + const delaysMs = [50, 100, 250, 500, 1000]; + for (let attempt = 0; ; attempt++) { + try { + fs.renameSync(source, target); + return; + } catch (cause) { + const code = (cause as NodeJS.ErrnoException).code; + if ((code !== "EBUSY" && code !== "EPERM") || attempt >= delaysMs.length) throw cause; + await new Promise((resolveDelay) => setTimeout(resolveDelay, delaysMs[attempt])); + } + } +}; + +const moveSqliteFileSet = async (source: string, target: string): Promise => { + await renameWithRetry(source, target); + for (const suffix of ["-wal", "-shm"] as const) { + if (fs.existsSync(`${source}${suffix}`)) + await renameWithRetry(`${source}${suffix}`, `${target}${suffix}`); + } +}; + +const removeSqliteFileSet = (path: string): void => { + for (const suffix of fileSetSuffixes) fs.rmSync(`${path}${suffix}`, { force: true }); +}; + +const backupPathFor = (sqlitePath: string): string => + `${sqlitePath}.v1-v2-${Date.now()}-${randomBytes(4).toString("hex")}`; + +const writeMigratedSecrets = async (input: { + readonly fileValues: Record; + readonly keychainValues: ReadonlyArray<{ readonly id: string; readonly value: string }>; +}): Promise => { + const newKeychain = makeKeychainProvider(keychainBaseServiceName()); + for (const entry of input.keychainValues) { + await Effect.runPromise(newKeychain.set!(entry.id as never, entry.value)); + } + writeFlatAuthFile(resolveFileAuthPath(), input.fileValues); +}; + +export const migrateLocalV1ToV2IfNeeded = async ( + options: LocalV1V2MigrationOptions, +): Promise => { + if (!fs.existsSync(options.sqlitePath)) return { migrated: false, warnings: [] }; + + const reader = await openLocalLibsql(options.sqlitePath); + try { + if (!(await isLocalV1Database(reader))) return { migrated: false, warnings: [] }; + const replayWarnings: string[] = []; + await replayLegacyV1Migrations(reader, replayWarnings); + const snapshot = await readV1Snapshot(reader, options.tenantId); + const plan = planMigration(snapshot.input); + const secretValues = await collectSecretValues(plan); + const oauthAuthorizationUrls = await resolveMigrationOAuthAuthorizationUrls(plan, { + fetch: options.oauthMetadataFetch ?? fetch, + timeoutMs: options.oauthMetadataTimeoutMs, + }); + const backupPath = backupPathFor(options.sqlitePath); + + reader.close(); + await moveSqliteFileSet(options.sqlitePath, backupPath); + + let target: Awaited> | null = null; + try { + target = await createSqliteFumaDb({ + tables: options.tables, + namespace: options.namespace, + path: options.sqlitePath, + }); + await insertPlan( + target.client, + snapshot, + plan, + secretValues.idOverrides, + secretValues.oauthClientIdValues, + oauthAuthorizationUrls, + options.tenantId, + ); + await target.close(); + target = null; + await writeMigratedSecrets(secretValues); + return { + migrated: true, + backupPath, + report: plan.report, + warnings: [...replayWarnings, ...plan.report.warnings, ...secretValues.warnings], + }; + } catch (cause) { + if (target) await target.close(); + removeSqliteFileSet(options.sqlitePath); + if (fs.existsSync(backupPath)) await moveSqliteFileSet(backupPath, options.sqlitePath); + throw cause; + } + } finally { + try { + reader.close(); + } catch { + // already closed after the snapshot was read + } + } +}; diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index 40091ab71..959fcdae8 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -1,68 +1,28 @@ -import { Context, Data, Effect, Layer, ManagedRuntime, Schema } from "effect"; -import { type Client } from "@libsql/client"; -import { drizzle } from "drizzle-orm/libsql"; -import { migrate } from "drizzle-orm/libsql/migrator"; -import { createHash, randomBytes } from "node:crypto"; +import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"; import * as fs from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; - -import { - Scope, - ScopeId, - createExecutor, - type AnyPlugin, - type Executor, - type FumaTables, -} from "@executor-js/sdk"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import { createHash } from "node:crypto"; + +import { Subject, Tenant, createExecutor, type AnyPlugin, type Executor } from "@executor-js/sdk"; import { collectTables } from "@executor-js/api/server"; -import { withQueryContext } from "fumadb/query"; import { loadPluginsFromJsonc } from "@executor-js/config"; import executorConfig from "../executor.config"; -import embeddedMigrations from "./db/embedded-migrations.gen"; -import { - importLegacySecrets, - moveAsidePreScopeDb, - readLegacySecrets, - type LegacySecret, -} from "./db/db-upgrade"; -import * as legacyExecutorSchema from "./db/executor-schema"; -import { - importSqliteDataToFuma, - readLegacySqliteScopeIds, - type LocalSqliteImportResult, -} from "./db/sqlite-import"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; -import { openLegacyLibsql, queryFirst, queryRows } from "./db/libsql"; -import { oneShotMigrateGoogleDiscoveryToOpenApi } from "./db/google-discovery-openapi-migration"; +import { migrateLocalV1ToV2IfNeeded } from "./db/v1-v2-migration"; interface ResolvedStorage { readonly dataDir: string; readonly sqlitePath: string; - readonly importMarkerPath: string; } const localNamespace = "executor_local"; -// In dev mode the drizzle folder sits next to the source tree. In a compiled -// binary the files are inlined by apps/cli/src/build.ts and extracted to a -// temp folder because drizzle's migrator accepts a folder path. -const resolveMigrationsFolder = (): string => { - if (!embeddedMigrations) { - return join(import.meta.dirname, "../drizzle"); - } - - const dir = fs.mkdtempSync(join(tmpdir(), "executor-migrations-")); - for (const [rel, content] of Object.entries(embeddedMigrations)) { - const target = join(dir, rel); - fs.mkdirSync(dirname(target), { recursive: true }); - fs.writeFileSync(target, content); - } - return dir; -}; - -const MIGRATIONS_FOLDER = resolveMigrationsFolder(); +// The single local subject. Local is single-user; the executor binds one +// tenant (the cwd-derived workspace) plus this subject so it can own both +// `owner: "org"` (workspace-shared) and `owner: "user"` connections. +const LOCAL_SUBJECT = "local"; const resolveStorage = (): ResolvedStorage => { const dataDir = process.env.EXECUTOR_DATA_DIR ?? join(homedir(), ".executor"); @@ -70,13 +30,12 @@ const resolveStorage = (): ResolvedStorage => { return { dataDir, sqlitePath: join(dataDir, "data.db"), - importMarkerPath: join(dataDir, "fumadb-sqlite-imported"), }; }; // Hash suffix disambiguates same-basename folders so two projects with -// identical directory names cannot collide on the same scope id. -const makeScopeId = (cwd: string): string => { +// identical directory names cannot collide on the same tenant id. +const makeTenantId = (cwd: string): string => { const folder = basename(cwd) || cwd; const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 8); return `${folder}-${hash}`; @@ -130,7 +89,6 @@ class LocalExecutorTag extends Context.Service {} @@ -140,23 +98,8 @@ class LocalExecutorDisposeError extends Data.TaggedError("LocalExecutorDisposeEr readonly cause: unknown; }> {} -class LocalSqliteCheckpointError extends Data.TaggedError("LocalSqliteCheckpointError")<{ - readonly path: string; - readonly busy: number; -}> {} - -const localExecutorCreateError = ( - operation: LocalExecutorCreateError["operation"], - cause: unknown, -) => - new LocalExecutorCreateError({ - operation, - cause, - message: - operation === "importSqlite" - ? "Failed to prepare local SQLite data. Close other Executor processes and retry, or run with --log-level debug for details." - : "Failed to open local SQLite data. Close other Executor processes and retry, or run with --log-level debug for details.", - }); +const CREATE_SQLITE_ERROR_MESSAGE = + "Failed to open local SQLite data. Close other Executor processes and retry, or run with --log-level debug for details."; const ignorePromiseFailure = ( operation: LocalExecutorDisposeError["operation"], @@ -183,510 +126,28 @@ const handleOrNull = (promise: ReturnType) => ), ); -const sqliteTableHasColumn = async ( - client: Client, - table: string, - column: string, -): Promise => { - const rows = await queryRows<{ name: string }>( - client, - `PRAGMA table_info('${table.replaceAll("'", "''")}')`, - ); - return rows.some((row) => row.name === column); -}; - -export const drizzleMigrationsTableExists = async (client: Client): Promise => { - const row = await queryFirst( - client, - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - ["__drizzle_migrations"], - ); - - return row != null; -}; - -export const readAppliedDrizzleMigrationHashes = async ( - client: Client, -): Promise> => { - if (!(await drizzleMigrationsTableExists(client))) return []; - - return ( - await queryRows<{ hash: string }>( - client, - "SELECT hash FROM __drizzle_migrations ORDER BY id ASC", - ) - ).map((row) => row.hash); -}; - -const DrizzleJournal = Schema.Struct({ - entries: Schema.Array( - Schema.Struct({ - idx: Schema.Number, - tag: Schema.String, - }), - ), -}); - -const decodeDrizzleJournal = Schema.decodeUnknownSync(Schema.fromJsonString(DrizzleJournal)); - -export const readBundledDrizzleMigrationHashes = ( - migrationsFolder: string, -): ReadonlyArray => { - const journal = decodeDrizzleJournal( - fs.readFileSync(join(migrationsFolder, "meta", "_journal.json")).toString(), - ); - - return [...journal.entries] - .sort((left, right) => left.idx - right.idx) - .map((entry) => { - const query = fs.readFileSync(join(migrationsFolder, `${entry.tag}.sql`)).toString(); - return createHash("sha256").update(query).digest("hex"); - }); -}; - -const hasBundledDrizzleMigrationPrefix = async (input: { - readonly client: Client; - readonly migrationsFolder: string; -}): Promise => { - if (!(await drizzleMigrationsTableExists(input.client))) return true; - - const applied = await readAppliedDrizzleMigrationHashes(input.client); - const bundled = readBundledDrizzleMigrationHashes(input.migrationsFolder); - return ( - applied.length <= bundled.length && applied.every((hash, index) => hash === bundled[index]) - ); -}; - -const isFumaSqliteDatabase = async (path: string): Promise => { - if (!fs.existsSync(path)) return false; - - let client: Client | null = null; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: native SQLite probe treats unreadable legacy files as non-FumaDB databases - try { - client = openLegacyLibsql(path); - const settings = await queryFirst( - client, - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", - [`private_${localNamespace}_settings`], - ); - return settings != null || (await sqliteTableHasColumn(client, "source", "row_id")); - } catch { - return false; - } finally { - client?.close(); - } -}; - -const removeSqliteFileSet = (path: string) => { - for (const suffix of ["", "-wal", "-shm"]) { - fs.rmSync(`${path}${suffix}`, { force: true }); - } -}; - -const removeSqliteSidecars = (path: string) => { - for (const suffix of ["-wal", "-shm"]) { - fs.rmSync(`${path}${suffix}`, { force: true }); - } -}; - -const moveSqliteFileSet = (source: string, target: string) => { - fs.renameSync(source, target); - for (const suffix of ["-wal", "-shm"]) { - if (fs.existsSync(`${source}${suffix}`)) { - fs.renameSync(`${source}${suffix}`, `${target}${suffix}`); - } - } -}; - -const moveSqliteFileSetToBackup = (path: string): string => { - const backupPath = `${path}.imported-${Date.now()}-${randomBytes(4).toString("hex")}`; - moveSqliteFileSet(path, backupPath); - return backupPath; -}; - -const checkpointSqliteForFileMove = async (input: { - readonly client: Client; - readonly path: string; -}): Promise => { - const checkpoint = await queryFirst<{ busy: number; log: number; checkpointed: number }>( - input.client, - "PRAGMA wal_checkpoint(TRUNCATE)", - ); - - if (checkpoint && checkpoint.busy !== 0) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: callers wrap this checkpoint-busy failure into LocalExecutorCreateError before a file move - throw new LocalSqliteCheckpointError({ path: input.path, busy: checkpoint.busy }); - } - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: DELETE mode is best-effort after a successful checkpoint; an open read handle can reject the mode switch without making the file set unsafe to move - try { - await input.client.execute("PRAGMA journal_mode = DELETE"); - } catch (cause) { - console.warn( - `[executor] Checkpointed SQLite WAL for ${input.path}, but could not switch journal mode to DELETE before import. Continuing with the checkpointed file set.`, - cause, - ); - } -}; - -const writeSqliteImportMarker = ( - markerPath: string, - input: { - readonly importedRows: number; - readonly importedTables: readonly string[]; - readonly backupPath?: string; - readonly recovered?: boolean; - }, -) => { - fs.mkdirSync(dirname(markerPath), { recursive: true }); - fs.writeFileSync( - markerPath, - `${JSON.stringify({ - importedAt: new Date().toISOString(), - importedRows: input.importedRows, - importedTables: input.importedTables, - backupPath: input.backupPath, - recovered: input.recovered === true ? true : undefined, - })}\n`, - { flag: "w" }, - ); -}; - -const SqliteImportMarkerSchema = Schema.Struct({ - importedTables: Schema.optional(Schema.Array(Schema.String)), - importedRows: Schema.optional(Schema.Number), - backupPath: Schema.optional(Schema.String), - recovered: Schema.optional(Schema.Boolean), -}); - -const decodeSqliteImportMarker = Schema.decodeUnknownSync( - Schema.fromJsonString(SqliteImportMarkerSchema), -); - -const normalizeSqliteImportMarker = (decoded: typeof SqliteImportMarkerSchema.Type) => ({ - importedRows: decoded.importedRows ?? 0, - importedTables: decoded.importedTables ?? [], - backupPath: decoded.backupPath, - recovered: decoded.recovered, -}); - -type SqliteImportMarker = ReturnType; - -const readSqliteImportMarker = (markerPath: string): SqliteImportMarker | null => { - if (!fs.existsSync(markerPath)) return null; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: malformed import markers are treated as incomplete so startup can re-check the database - try { - return normalizeSqliteImportMarker( - decodeSqliteImportMarker(fs.readFileSync(markerPath).toString()), - ); - } catch { - return null; - } -}; - -const pickFumaTables = (tables: FumaTables, names: ReadonlySet): FumaTables => { - const picked: FumaTables = {}; - for (const [name, table] of Object.entries(tables)) { - if (names.has(name)) picked[name] = table; - } - return picked; -}; - -const replaceSqliteFileSetWithRollback = (input: { - readonly sourcePath: string; - readonly targetPath: string; -}): string => { - const backupPath = moveSqliteFileSetToBackup(input.sourcePath); - removeSqliteSidecars(backupPath); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local DB replacement must restore the original file set if the swap fails halfway - try { - moveSqliteFileSet(input.targetPath, input.sourcePath); - return backupPath; - } catch (cause) { - removeSqliteFileSet(input.sourcePath); - if (fs.existsSync(backupPath)) { - moveSqliteFileSet(backupPath, input.sourcePath); - } - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve the original replacement failure after rollback - throw cause; - } -}; - -const createLegacySecretRows = (scopeId: string, secrets: readonly LegacySecret[]) => - secrets.map((secret) => ({ - id: secret.id, - scope_id: scopeId, - name: secret.name, - provider: secret.provider, - owned_by_connection_id: null, - created_at: new Date(secret.createdAt), - })); - -interface PreparedLegacySqlite { - readonly legacySecrets: readonly LegacySecret[]; - readonly preScopeBackup?: string; -} - -const prepareLegacySqliteForFumaImport = async (input: { - readonly storage: ResolvedStorage; - readonly scopeId: string; -}): Promise => { - if ( - !fs.existsSync(input.storage.sqlitePath) || - (await isFumaSqliteDatabase(input.storage.sqlitePath)) - ) { - return { legacySecrets: [] }; - } - - const legacySecrets = await readLegacySecrets(input.storage.sqlitePath); - const preScopeBackup = await moveAsidePreScopeDb(input.storage.sqlitePath); - if (preScopeBackup) { - console.warn( - `[executor] Pre-scope database detected; moved to ${preScopeBackup}. ` + - `Sources and tool catalogs will need to be re-added` + - (legacySecrets.length > 0 - ? ` (${legacySecrets.length} secret routing row(s) preserved).` - : "."), - ); - return { legacySecrets, preScopeBackup }; - } - - const client = openLegacyLibsql(input.storage.sqlitePath); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: legacy migration preflight must close SQLite before the FumaDB import re-opens the file - try { - if (await hasBundledDrizzleMigrationPrefix({ client, migrationsFolder: MIGRATIONS_FOLDER })) { - await client.execute("PRAGMA journal_mode = WAL"); - await migrate(drizzle({ client, schema: legacyExecutorSchema }), { - migrationsFolder: MIGRATIONS_FOLDER, - }); - await importLegacySecrets(client, input.scopeId, legacySecrets); - } else { - console.warn( - `[executor] Local SQLite migration history in ${input.storage.dataDir} ` + - `does not match this build's bundled legacy migrations. ` + - `Skipping legacy Drizzle replay and importing the existing schema as-is.`, - ); - } - await checkpointSqliteForFileMove({ client, path: input.storage.sqlitePath }); - return { legacySecrets: [] }; - } finally { - client.close(); - } -}; - -const importMissingMarkedTables = async (input: { - readonly storage: ResolvedStorage; - readonly marker: SqliteImportMarker; - readonly tables: FumaTables; - readonly scopeId: string; -}): Promise => { - const alreadyImported = new Set(input.marker.importedTables); - const missingTables = Object.keys(input.tables).filter((table) => !alreadyImported.has(table)); - if ( - !input.marker.backupPath || - missingTables.length === 0 || - !fs.existsSync(input.marker.backupPath) - ) { - return { imported: false, importedRows: 0, importedTables: [] }; - } - - const missingTableSet = new Set(missingTables); - const target = await createSqliteFumaDb({ - tables: input.tables, - namespace: localNamespace, - path: input.storage.sqlitePath, - }); - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: late plugin-table imports must close the active SQLite handle on failure - try { - const pickedTables = pickFumaTables(input.tables, missingTableSet); - const legacyScopeIds = await readLegacySqliteScopeIds({ - sqlitePath: input.marker.backupPath, - tables: pickedTables, - scopeId: input.scopeId, - }); - const result = await importSqliteDataToFuma({ - sqlitePath: input.marker.backupPath, - target: withQueryContext(target.db, { - allowedScopeIds: legacyScopeIds, - }), - tables: pickedTables, - scopeId: input.scopeId, - }); - await checkpointSqliteForFileMove({ client: target.client, path: input.storage.sqlitePath }); - await target.close(); - removeSqliteSidecars(input.storage.sqlitePath); - - const importedTables = [...new Set([...input.marker.importedTables, ...missingTables])]; - writeSqliteImportMarker(input.storage.importMarkerPath, { - importedRows: input.marker.importedRows + result.importedRows, - importedTables, - backupPath: input.marker.backupPath, - recovered: input.marker.recovered, - }); - - return result.importedRows > 0 || result.importedTables.length > 0 - ? result - : { imported: false, importedRows: 0, importedTables: [] }; - } catch (cause) { - await target.close(); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve late plugin-table import failure after closing SQLite - throw cause; - } -}; - -export const importLegacySqliteIfNeeded = async (options: { - readonly storage: ResolvedStorage; - readonly tables: ReturnType; - readonly scopeId: string; -}) => { - const { storage, tables, scopeId } = options; - const targetPath = `${storage.sqlitePath}.fumadb-next`; - const marker = readSqliteImportMarker(storage.importMarkerPath); - - if (marker) { - return importMissingMarkedTables({ - storage, - marker, - tables, - scopeId, - }); - } - if (fs.existsSync(storage.importMarkerPath)) { - fs.rmSync(storage.importMarkerPath, { force: true }); - } - - if (!fs.existsSync(storage.importMarkerPath) && fs.existsSync(storage.sqlitePath)) { - if (await isFumaSqliteDatabase(storage.sqlitePath)) { - writeSqliteImportMarker(storage.importMarkerPath, { - importedRows: 0, - importedTables: [], - recovered: true, - }); - } else { - const prepared = await prepareLegacySqliteForFumaImport({ storage, scopeId }); - if (prepared.preScopeBackup) { - if (prepared.legacySecrets.length > 0) { - const target = await createSqliteFumaDb({ - tables, - namespace: localNamespace, - path: storage.sqlitePath, - }); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: pre-scope secret import must close the fresh FumaDB handle on failure - try { - await withQueryContext(target.db, { - allowedScopeIds: new Set([scopeId]), - }).createMany("secret", createLegacySecretRows(scopeId, prepared.legacySecrets)); - await checkpointSqliteForFileMove({ client: target.client, path: storage.sqlitePath }); - } finally { - await target.close(); - removeSqliteSidecars(storage.sqlitePath); - } - } - writeSqliteImportMarker(storage.importMarkerPath, { - importedRows: prepared.legacySecrets.length, - importedTables: prepared.legacySecrets.length > 0 ? ["secret"] : [], - backupPath: prepared.preScopeBackup, - }); - return { - imported: prepared.legacySecrets.length > 0, - importedRows: prepared.legacySecrets.length, - importedTables: prepared.legacySecrets.length > 0 ? ["secret"] : [], - backupPath: prepared.preScopeBackup, - }; - } - } - } - - if ( - !fs.existsSync(storage.importMarkerPath) && - !fs.existsSync(storage.sqlitePath) && - fs.existsSync(targetPath) && - (await isFumaSqliteDatabase(targetPath)) - ) { - moveSqliteFileSet(targetPath, storage.sqlitePath); - writeSqliteImportMarker(storage.importMarkerPath, { - importedRows: 0, - importedTables: [], - recovered: true, - }); - } - - if ( - !fs.existsSync(storage.sqlitePath) || - fs.existsSync(storage.importMarkerPath) || - (await isFumaSqliteDatabase(storage.sqlitePath)) - ) { - return { imported: false, importedRows: 0, importedTables: [] }; - } - - removeSqliteFileSet(targetPath); - - const target = await createSqliteFumaDb({ - tables, - namespace: localNamespace, - path: targetPath, - }); - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local SQLite cutover must close and remove the temporary target database on import failure - try { - const legacyScopeIds = await readLegacySqliteScopeIds({ - sqlitePath: storage.sqlitePath, - tables, - scopeId, - }); - const result = await importSqliteDataToFuma({ - sqlitePath: storage.sqlitePath, - target: withQueryContext(target.db, { - allowedScopeIds: legacyScopeIds, - }), - tables, - scopeId, - }); - await checkpointSqliteForFileMove({ client: target.client, path: targetPath }); - await target.close(); - removeSqliteSidecars(targetPath); - - if (result.imported) { - const backupPath = replaceSqliteFileSetWithRollback({ - sourcePath: storage.sqlitePath, - targetPath, - }); - writeSqliteImportMarker(storage.importMarkerPath, { - importedRows: result.importedRows, - importedTables: result.importedTables, - backupPath, - }); - return { ...result, backupPath }; - } else { - removeSqliteFileSet(targetPath); - } - return result; - } catch (cause) { - await target.close(); - removeSqliteFileSet(targetPath); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: preserve the original import failure after temp-file cleanup - throw cause; - } -}; - const createLocalExecutorLayer = () => { const storage = resolveStorage(); return Layer.effect(LocalExecutorTag)( Effect.gen(function* () { const { cwd, plugins } = yield* loadLocalPlugins; - const scopeId = makeScopeId(cwd); + const tenantId = makeTenantId(cwd); const tables = collectTables(); - const importResult = yield* Effect.tryPromise({ + const migration = yield* Effect.tryPromise({ try: () => - importLegacySqliteIfNeeded({ - storage, + migrateLocalV1ToV2IfNeeded({ + sqlitePath: storage.sqlitePath, tables, - scopeId, + namespace: localNamespace, + tenantId, + }), + catch: (cause) => + new LocalExecutorCreateError({ + message: CREATE_SQLITE_ERROR_MESSAGE, + cause, }), - catch: (cause) => localExecutorCreateError("importSqlite", cause), }); const sqlite = yield* Effect.acquireRelease( @@ -697,51 +158,49 @@ const createLocalExecutorLayer = () => { namespace: localNamespace, path: storage.sqlitePath, }), - catch: (cause) => localExecutorCreateError("createSqlite", cause), + catch: (cause) => + new LocalExecutorCreateError({ + message: CREATE_SQLITE_ERROR_MESSAGE, + cause, + }), }), (db) => Effect.promise(() => db.close()).pipe(Effect.ignore), ); - const migratedGoogleDiscoverySources = yield* Effect.promise(() => - oneShotMigrateGoogleDiscoveryToOpenApi(sqlite.client), - ); - - if (importResult.imported) { - console.warn( - `[executor] Imported ${importResult.importedRows} row(s) into FumaDB SQLite storage` + - (importResult.backupPath ? `; moved old DB to ${importResult.backupPath}.` : "."), - ); - } - if (migratedGoogleDiscoverySources > 0) { - console.warn( - `[executor] Migrated ${migratedGoogleDiscoverySources} Google Discovery source(s) to OpenAPI storage.`, - ); - } - - const scope = Scope.make({ - id: ScopeId.make(scopeId), - name: cwd, - createdAt: new Date(), - }); + // webBaseUrl is where the executor's web UI listens — same port as the + // daemon API since the daemon serves both. Mirrors serve.ts's port + // resolution so a custom $PORT flows through. EXECUTOR_WEB_BASE_URL + // overrides entirely for deployments where the UI is on a different host. + const webBaseUrl = + process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`; const executor = yield* createExecutor({ - scopes: [scope], + tenant: Tenant.make(tenantId), + subject: Subject.make(LOCAL_SUBJECT), db: sqlite.db, plugins, onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, - // Built-in agent-facing tools (scopes.list, secrets.list, - // secrets.create). webBaseUrl is where the executor's web UI - // listens — same port as the daemon API since the daemon serves - // both. Mirrors serve.ts's port resolution so a custom $PORT - // flows through. EXECUTOR_WEB_BASE_URL overrides entirely for - // deployments where the UI is on a different host. + // EXPLICIT OAuth callback — the daemon serves the v2 `/api/oauth/callback` + // route on the same origin as the web UI. Derived from `webBaseUrl` + // (loopback localhost is correct + intended for the local CLI, but it + // is wired explicitly here rather than relying on a hidden default). + redirectUri: new URL("/api/oauth/callback", webBaseUrl).toString(), + // Built-in agent-facing tools (integrations / connections / policies). coreTools: { - webBaseUrl: - process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`, + webBaseUrl, }, }); + if (migration.migrated) { + console.warn( + `[executor] Migrated local Executor data to v2; moved old DB to ${migration.backupPath}.`, + ); + for (const warning of migration.warnings) { + console.warn(`[executor] local v2 migration: ${warning}`); + } + } + return { executor, plugins }; }), ); diff --git a/apps/local/src/mcp-browser-resume.test.ts b/apps/local/src/mcp-browser-resume.test.ts index cd79d3a79..6c2b6cc1e 100644 --- a/apps/local/src/mcp-browser-resume.test.ts +++ b/apps/local/src/mcp-browser-resume.test.ts @@ -25,8 +25,8 @@ import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { collectTables } from "@executor-js/api/server"; import { FormElicitation, - Scope, - ScopeId, + Subject, + Tenant, createExecutor, definePlugin, type Executor, @@ -83,14 +83,10 @@ const makeExecutor = async (tmpDir: string): Promise => { namespace: "executor_local_browser_resume_test", path: join(tmpDir, "data.db"), }); - const scope = Scope.make({ - id: ScopeId.make(`test-${randomBytes(4).toString("hex")}`), - name: "test", - createdAt: new Date(), - }); const executor = await Effect.runPromise( createExecutor({ - scopes: [scope], + tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), + subject: Subject.make("local"), db: sqlite.db, plugins, onElicitation: "accept-all", @@ -109,7 +105,10 @@ const makeExecutor = async (tmpDir: string): Promise => { }; const makeMcpFetch = (executor: Executor) => { - const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }); const mcp = createMcpRequestHandler({ engine }); const fetchImpl: typeof globalThis.fetch = Object.assign( @@ -212,7 +211,9 @@ describe("local MCP browser approval resume", () => { data: expect.objectContaining({ response: expect.objectContaining({ action: "accept", - content: expect.objectContaining({ note: expect.stringContaining("approved-") }), + content: expect.objectContaining({ + note: expect.stringContaining("approved-"), + }), }), }), }), diff --git a/apps/local/src/mcp-oauth.test.ts b/apps/local/src/mcp-oauth.test.ts index 67e02d410..3c9d84823 100644 --- a/apps/local/src/mcp-oauth.test.ts +++ b/apps/local/src/mcp-oauth.test.ts @@ -1,21 +1,23 @@ // --------------------------------------------------------------------------- -// Local app × MCP OAuth — real HTTP end-to-end +// Local app × OAuth — real HTTP end-to-end (v2) // --------------------------------------------------------------------------- // -// Mirrors apps/cloud/src/services/mcp-oauth.node.test.ts but for the local -// (sqlite) server. Drives the real LocalApi (core + mcp groups) against a -// real in-process OAuth + MCP server. Every layer between the test and the -// plugin is real: +// Drives the real LocalApi (core + mcp groups) against a real in-process OAuth +// test server. Every layer between the test and the SDK is real: // // test → HttpApiClient → in-process webHandler → LocalApi -// → McpHandlers → mcpPlugin.startOAuth / completeOAuth -// → MCP SDK `auth()` -// → OAuthTestServer (DCR, /authorize → login, /token, AS metadata, -// protected resource metadata, MCP protected resource) +// → OAuthHandlers → executor.oauth.{probe,createClient,start} +// → OAuthTestServer (AS metadata, protected-resource metadata, DCR, +// /authorize → login, /token) // -// Single-scope: local has one scope per project (`${folder}-${hash}`) so -// the OAuth flow lands tokens at that scope and `secrets.resolve` reads -// them back through the same provider (file-secrets in a tmpdir). +// v2: OAuth is a credential mechanism on the core surface (`executor.oauth`), +// not a plugin-specific MCP handoff. `probe` (RFC 8414 / OIDC discovery), +// `createClient`, and `start`/`complete` (milestone 2) are all implemented. This +// test asserts the live discovery path AND that `start` returns an authorization +// redirect (PKCE + correlation state) over the real HTTP boundary. +// +// Single workspace: local binds one tenant per project (`${folder}-${hash}`) +// plus a fixed subject, so owner: "org" connections file at the tenant. // --------------------------------------------------------------------------- import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; @@ -37,7 +39,15 @@ import { } from "@executor-js/api/server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; -import { Scope, ScopeId, createExecutor } from "@executor-js/sdk"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + Subject, + Tenant, + createExecutor, +} from "@executor-js/sdk"; import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { mcpPlugin } from "@executor-js/plugin-mcp"; @@ -46,9 +56,9 @@ import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin- import { ErrorCaptureLive } from "./observability"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; -// Shape of the test API: core + mcp group, with InternalError surfaced at -// the top level so `observabilityMiddleware` can land its typed-error -// bridge on every endpoint. +// Shape of the test API: core (incl. the oauth group) + mcp group, with +// InternalError surfaced at the top level so `observabilityMiddleware` can land +// its typed-error bridge on every endpoint. const TestApi = addGroup(McpGroup); type TestApiShape = typeof TestApi extends HttpApi.HttpApi @@ -63,12 +73,10 @@ const TEST_BASE_URL = "http://local.test"; interface Harness { readonly fetch: typeof globalThis.fetch; - readonly scopeId: string; readonly dispose: () => Promise; } const startHarness = async (tmpDir: string): Promise => { - const scopeId = `test-${randomBytes(4).toString("hex")}`; const plugins = [ mcpPlugin({ dangerouslyAllowStdioMCP: false }), fileSecretsPlugin({ directory: tmpDir }), @@ -79,19 +87,17 @@ const startHarness = async (tmpDir: string): Promise => { path: join(tmpDir, "data.db"), }); - const scope = Scope.make({ - id: ScopeId.make(scopeId), - name: "test", - createdAt: new Date(), - }); - const executor = await Effect.runPromise( createExecutor({ - scopes: [scope], + tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), + subject: Subject.make("local"), db: sqlite.db, plugins, onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, + // EXPLICIT OAuth callback — required now that the localhost default is + // gone; the local daemon serves `/api/oauth/callback` on the web origin. + redirectUri: "http://localhost:4788/api/oauth/callback", }), ); @@ -126,7 +132,6 @@ const startHarness = async (tmpDir: string): Promise => { webHandler( input instanceof Request ? input : new Request(input, init), )) as typeof globalThis.fetch, - scopeId, dispose: async () => { await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => disposeHandler()))); await Effect.runPromise( @@ -158,9 +163,9 @@ afterAll(async () => { // Test // --------------------------------------------------------------------------- -describe("local mcp oauth (real OAuth + MCP server)", () => { +describe("local oauth (real OAuth discovery + stubbed start)", () => { it.effect( - "startOAuth → authorize → completeOAuth mints a Connection at the scope", + "probe discovers the authorization server; start returns an authorization redirect", () => Effect.scoped( Effect.gen(function* () { @@ -169,11 +174,6 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), ); - const namespace = `ns_${randomBytes(4).toString("hex")}`; - const connectionId = `mcp-oauth2-${namespace}`; - const redirectUrl = "http://local.test/api/mcp/oauth/callback"; - const scopeId = ScopeId.make(harness.scopeId); - const run = ( body: (client: TestApiShape) => Effect.Effect, ): Effect.Effect => @@ -184,34 +184,51 @@ describe("local mcp oauth (real OAuth + MCP server)", () => { return yield* body(client); }).pipe(Effect.provide(clientLayer)) as Effect.Effect; + // probe — real RFC 8414 / OIDC discovery against the test server. + const probed = yield* run((client) => + client.oauth.probe({ payload: { url: oauth.mcpResourceUrl } }), + ); + expect(probed.authorizationUrl).toBe(oauth.authorizationEndpoint); + expect(probed.tokenUrl).toBe(oauth.tokenEndpoint); + + // createClient — register an owner-scoped OAuth app for the start flow. + const slug = `mcp-oauth2-${randomBytes(4).toString("hex")}`; + const created = yield* run((client) => + client.oauth.createClient({ + payload: { + owner: "org", + slug: OAuthClientSlug.make(slug), + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }, + }), + ); + expect(String(created.client)).toBe(slug); + + // start — milestone 2 wired: authorization_code returns a redirect to + // the authorization server (with PKCE + a correlation state). const started = yield* run((client) => client.oauth.start({ - params: { scopeId }, payload: { - endpoint: oauth.mcpResourceUrl, - redirectUrl, - connectionId, - tokenScope: String(scopeId), - strategy: { kind: "dynamic-dcr" }, - pluginId: "mcp", + client: OAuthClientSlug.make(slug), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("mcp_remote"), + template: AuthTemplateSlug.make("oauth"), }, }), ); - expect(started.sessionId).toMatch(/^oauth2_session_/); - expect(started.authorizationUrl).not.toBeNull(); - - const { code, state } = yield* oauth.completeAuthorizationCodeFlow({ - authorizationUrl: started.authorizationUrl!, - }); - expect(state).toBe(started.sessionId); - - const completed = yield* run((client) => - client.oauth.complete({ - params: { scopeId }, - payload: { state, code }, - }), + expect(started.status).toBe("redirect"); + const redirect = started as Extract; + expect(redirect.authorizationUrl).toContain(oauth.authorizationEndpoint); + expect(redirect.authorizationUrl).toContain( + encodeURIComponent("http://localhost:4788/api/oauth/callback"), ); - expect(completed.connectionId).toBe(connectionId); + expect(redirect.state).toBeTruthy(); }), ), 30_000, diff --git a/apps/local/src/testing/libsql-test-db.ts b/apps/local/src/testing/libsql-test-db.ts deleted file mode 100644 index 45242f8e1..000000000 --- a/apps/local/src/testing/libsql-test-db.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { createClient, type Client, type InArgs, type Row } from "@libsql/client"; -import { drizzle } from "drizzle-orm/libsql"; -import { migrate } from "drizzle-orm/libsql/migrator"; -import { resolve } from "node:path"; - -// --------------------------------------------------------------------------- -// Async libSQL test helper for the local migration/import suites. These tests -// used to open a synchronous bun:sqlite `Database` and call -// `.exec(sql)` / `.prepare(sql).run(...args)` / `.get(...args)` / `.all(...args)`. -// libSQL is async, so this thin wrapper keeps the same call shape (just awaited) -// over a single libSQL connection to the same `file:` URL — letting the suites -// run under plain Node vitest with no bun:sqlite dependency. -// --------------------------------------------------------------------------- - -const toUrl = (path: string): string => (path === ":memory:" ? path : `file:${resolve(path)}`); - -export class LibsqlTestDb { - readonly client: Client; - - constructor(path: string = ":memory:") { - this.client = createClient({ url: toUrl(path) }); - } - - /** Run one or more `;`-separated statements (bun:sqlite `.exec`). */ - async exec(sql: string): Promise { - await this.client.executeMultiple(sql); - } - - /** Run a parameterized statement (bun:sqlite `.prepare(sql).run(...args)`). */ - async run(sql: string, ...args: unknown[]): Promise { - await this.client.execute({ sql, args: args as InArgs }); - } - - /** First row of a query (bun:sqlite `.prepare(sql).get(...args)`), or undefined. */ - async get(sql: string, ...args: unknown[]): Promise { - return (await this.client.execute({ sql, args: args as InArgs })).rows[0] as T | undefined; - } - - /** All rows of a query (bun:sqlite `.prepare(sql).all(...args)`). */ - async all(sql: string, ...args: unknown[]): Promise { - // oxlint-disable-next-line executor/no-double-cast -- boundary: test helper narrows libSQL's structural `Row[]` to the caller's row type (the SQL is the contract) - return (await this.client.execute({ sql, args: args as InArgs })).rows as unknown as T[]; - } - - /** - * Prepared-statement shape mirroring bun:sqlite's `.prepare(sql)` so existing - * suites keep their `.run(...) / .get(...) / .all(...)` chains (just awaited). - */ - prepare(sql: string): LibsqlPreparedStatement { - return new LibsqlPreparedStatement(this.client, sql); - } - - close(): void { - this.client.close(); - } -} - -export class LibsqlPreparedStatement { - constructor( - private readonly client: Client, - private readonly sql: string, - ) {} - - async run(...args: unknown[]): Promise { - await this.client.execute({ sql: this.sql, args: args as InArgs }); - } - - async get(...args: unknown[]): Promise { - return (await this.client.execute({ sql: this.sql, args: args as InArgs })).rows[0] as - | T - | undefined; - } - - async all(...args: unknown[]): Promise { - // oxlint-disable-next-line executor/no-double-cast -- boundary: test helper narrows libSQL's structural `Row[]` to the caller's row type (the SQL is the contract) - return (await this.client.execute({ sql: this.sql, args: args as InArgs })) - .rows as unknown as T[]; - } -} - -/** Open a fresh in-memory or file-backed libSQL test DB. */ -export const openTestDb = (path?: string): LibsqlTestDb => new LibsqlTestDb(path); - -/** Open a libSQL client for a file path (caller closes it). */ -export const openTestClient = (path: string): Client => createClient({ url: toUrl(path) }); - -/** - * Replays drizzle migrations against a file DB through the libSQL migrator - * (replaces `migrate(drizzle(new Database(path)), { migrationsFolder })`). Opens - * and closes its own connection. - */ -export const runMigrations = async (path: string, migrationsFolder: string): Promise => { - const client = createClient({ url: toUrl(path) }); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test migrator must close its connection whether or not the migration throws - try { - await migrate(drizzle({ client }), { migrationsFolder }); - } finally { - client.close(); - } -}; diff --git a/apps/local/src/testing/pre-0007-schema.ts b/apps/local/src/testing/pre-0007-schema.ts deleted file mode 100644 index b4e44a3b3..000000000 --- a/apps/local/src/testing/pre-0007-schema.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Shared pre-migration schema used by the four migrate-*-bindings.test.ts -// suites. Each test seeds this hand-rolled DDL (the DB shape immediately -// after 0006_neat_terror), then runs drizzle's migrator which executes -// only `0007_normalize_plugin_secret_refs.sql` thanks to the stamp. - -import { type LibsqlTestDb } from "./libsql-test-db"; - -export const PRE_0007_SQL = ` - CREATE TABLE __drizzle_migrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hash TEXT NOT NULL, - created_at NUMERIC - ); - - CREATE TABLE graphql_source ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - name TEXT NOT NULL, - endpoint TEXT NOT NULL, - headers TEXT, - query_params TEXT, - auth TEXT, - PRIMARY KEY (scope_id, id) - ); - - CREATE TABLE graphql_operation ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - source_id TEXT NOT NULL, - binding TEXT NOT NULL, - PRIMARY KEY (scope_id, id) - ); - - CREATE TABLE openapi_source ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - name TEXT NOT NULL, - spec TEXT NOT NULL, - source_url TEXT, - base_url TEXT, - headers TEXT, - query_params TEXT, - oauth2 TEXT, - invocation_config TEXT NOT NULL, - PRIMARY KEY (scope_id, id) - ); - - CREATE TABLE openapi_source_binding ( - id TEXT PRIMARY KEY NOT NULL, - source_id TEXT NOT NULL, - source_scope_id TEXT NOT NULL, - target_scope_id TEXT NOT NULL, - slot TEXT NOT NULL, - value TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE secret ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - name TEXT NOT NULL, - provider TEXT NOT NULL, - owned_by_connection_id TEXT, - created_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - - CREATE INDEX secret_scope_id_idx ON secret (scope_id); - CREATE INDEX secret_provider_idx ON secret (provider); - CREATE INDEX secret_owned_by_connection_id_idx ON secret (owned_by_connection_id); - - CREATE TABLE connection ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - provider TEXT NOT NULL, - identity_label TEXT, - access_token_secret_id TEXT NOT NULL, - refresh_token_secret_id TEXT, - expires_at INTEGER, - scope TEXT, - provider_state TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - - CREATE INDEX connection_scope_id_idx ON connection (scope_id); - CREATE INDEX connection_provider_idx ON connection (provider); - - CREATE TABLE mcp_source ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - name TEXT NOT NULL, - config TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - - CREATE TABLE mcp_binding ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - source_id TEXT NOT NULL, - binding TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - - CREATE TABLE google_discovery_source ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - name TEXT NOT NULL, - config TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); - - CREATE TABLE google_discovery_binding ( - id TEXT NOT NULL, - scope_id TEXT NOT NULL, - source_id TEXT NOT NULL, - binding TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (scope_id, id) - ); -`; - -// 0006_neat_terror.when. drizzle's sqlite migrator picks the latest -// `created_at` from __drizzle_migrations and skips any migration whose -// folderMillis (from the journal) is <= that timestamp. -export const STAMP_BEFORE = 1777850000001; - -export const stampPriorMigrationsApplied = async (db: LibsqlTestDb): Promise => { - await db.run( - "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)", - "pre-0007-marker", - STAMP_BEFORE, - ); -}; diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts index aab4f7d23..3debb41e6 100644 --- a/apps/local/vite.config.ts +++ b/apps/local/vite.config.ts @@ -140,7 +140,7 @@ export default defineConfig({ // Workspace packages live under packages/ and are symlinked into // node_modules. Without this, chokidar treats them as ordinary // node_modules and skips watching, so edits to e.g. - // packages/react/src/pages/sources.tsx don't trigger HMR. + // packages/react/src/pages/integrations.tsx don't trigger HMR. ignored: ["!**/node_modules/@executor-js/**"], // WSL2 + symlinked workspace packages can drop inotify events; // polling is slower but reliable. diff --git a/bun.lock b/bun.lock index 6207f90d3..f464f0a96 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "executor-workspace", "devDependencies": { + "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.30.0", "@effect/language-service": "^0.85.1", "@effect/tsgo": "^0.5.2", @@ -29,7 +30,7 @@ }, "apps/cli": { "name": "executor", - "version": "1.4.33", + "version": "1.5.2", "bin": { "executor": "./bin/executor.ts", }, @@ -53,7 +54,7 @@ }, "apps/cloud": { "name": "@executor-js/cloud", - "version": "1.4.19", + "version": "1.4.22", "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", "@effect/atom-react": "catalog:", @@ -105,7 +106,6 @@ "@electric-sql/pglite": "^0.4.4", "@electric-sql/pglite-socket": "^0.1.4", "@executor-js/cli": "workspace:*", - "@playwright/test": "^1.60.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", @@ -114,7 +114,6 @@ "concurrently": "^9.2.1", "drizzle-kit": "catalog:", "jiti": "^2.6.1", - "playwright": "^1.60.0", "typescript": "catalog:", "vite": "catalog:", "vitest": "^4.1.5", @@ -123,7 +122,7 @@ }, "apps/desktop": { "name": "@executor-js/desktop", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "electron-log": "^5", "electron-store": "^10", @@ -199,7 +198,7 @@ }, "apps/host-selfhost": { "name": "@executor-js/host-selfhost", - "version": "0.0.0", + "version": "0.0.3", "dependencies": { "@better-auth/api-key": "^1.6.11", "@effect/atom-react": "catalog:", @@ -317,9 +316,34 @@ "wrangler": "^4.0.0", }, }, + "e2e": { + "name": "@executor-js/e2e", + "version": "0.0.1", + "dependencies": { + "@executor-js/api": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@kitlangton/terminal-control": "^0.3.0", + "effect": "catalog:", + "monaco-editor": "^0.55.1", + "playwright": "^1.60.0", + "react": "catalog:", + "react-dom": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "vite": "catalog:", + "vitest": "catalog:", + }, + }, "examples/all-plugins": { "name": "@executor-js/example-all-plugins", - "version": "0.0.19", + "version": "0.0.22", "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -338,10 +362,11 @@ }, "examples/docs-sdk-quickstart": { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.4", + "version": "0.0.7", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", + "effect": "catalog:", }, "devDependencies": { "@types/node": "catalog:", @@ -389,7 +414,7 @@ }, "packages/core/api": { "name": "@executor-js/api", - "version": "1.4.21", + "version": "1.4.24", "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", @@ -406,7 +431,7 @@ }, "packages/core/cli": { "name": "@executor-js/cli", - "version": "0.2.5", + "version": "0.2.8", "bin": { "executor-sdk": "./dist/index.js", }, @@ -427,10 +452,9 @@ }, "packages/core/config": { "name": "@executor-js/config", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@executor-js/sdk": "workspace:*", - "effect": "catalog:", "jiti": "^2.6.1", "jsonc-parser": "^3.3.1", }, @@ -438,28 +462,35 @@ "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/core/execution": { "name": "@executor-js/execution", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@executor-js/codemode-core": "workspace:*", "@executor-js/sdk": "workspace:*", - "effect": "catalog:", }, "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/runtime-quickjs": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/core/fumadb": { "name": "fumadb", @@ -507,10 +538,9 @@ }, "packages/core/sdk": { "name": "@executor-js/sdk", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@standard-schema/spec": "^1.1.0", - "effect": "catalog:", "fractional-indexing": "^3.2.0", "fumadb": "workspace:*", "oauth4webapi": "^3.8.5", @@ -523,6 +553,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "drizzle-orm": "catalog:", + "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", "typescript": "catalog:", @@ -531,6 +562,7 @@ "peerDependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", + "effect": "catalog:", "react": "catalog:", }, "optionalPeers": [ @@ -558,7 +590,7 @@ }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", - "version": "0.0.18", + "version": "0.0.21", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -578,7 +610,7 @@ }, "packages/hosts/cloudflare": { "name": "@executor-js/cloudflare", - "version": "0.0.0", + "version": "0.0.3", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/execution": "workspace:*", @@ -616,23 +648,26 @@ }, "packages/kernel/core": { "name": "@executor-js/codemode-core", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@babel/parser": "^7.29.2", "@standard-schema/spec": "^1.0.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "effect": "catalog:", "sucrase": "^3.35.1", }, "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/kernel/ir": { "name": "@executor-js/ir", @@ -686,24 +721,27 @@ }, "packages/kernel/runtime-quickjs": { "name": "@executor-js/runtime-quickjs", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@executor-js/codemode-core": "workspace:*", - "effect": "catalog:", "quickjs-emscripten": "catalog:", }, "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@executor-js/sdk": "workspace:*", "react": "catalog:", @@ -716,7 +754,7 @@ }, "packages/plugins/encrypted-secrets": { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.0", + "version": "0.0.3", "dependencies": { "@executor-js/sdk": "workspace:*", "effect": "catalog:", @@ -731,7 +769,7 @@ }, "packages/plugins/example": { "name": "@executor-js/plugin-example", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -754,26 +792,28 @@ }, "packages/plugins/file-secrets": { "name": "@executor-js/plugin-file-secrets", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@executor-js/sdk": "workspace:*", - "effect": "catalog:", }, "devDependencies": { "@types/node": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "vitest": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/plugins/graphql": { "name": "@executor-js/plugin-graphql", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", - "effect": "catalog:", "graphql": "^16.12.0", "graphql-yoga": "^5.17.0", }, @@ -785,6 +825,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:", @@ -794,6 +835,7 @@ "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "@tanstack/react-router": "catalog:", + "effect": "catalog:", "react": "catalog:", }, "optionalPeers": [ @@ -804,32 +846,85 @@ "react", ], }, + "packages/plugins/graphql-greenfield": { + "name": "@executor-js/plugin-graphql-greenfield", + "version": "0.0.3", + "dependencies": { + "@effect/platform-node": "catalog:", + "@executor-js/sdk": "workspace:*", + "effect": "catalog:", + "graphql": "^16.12.0", + "graphql-yoga": "^5.17.0", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/api": "workspace:*", + "@types/node": "catalog:", + "bun-types": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "@executor-js/api": "workspace:*", + }, + "optionalPeers": [ + "@executor-js/api", + ], + }, + "packages/plugins/http-source": { + "name": "@executor-js/plugin-http-source", + "version": "2.0.3", + "dependencies": { + "@executor-js/sdk": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@executor-js/react": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "bun-types": "catalog:", + "react": "catalog:", + "tsup": "catalog:", + "vitest": "catalog:", + }, + "peerDependencies": { + "@executor-js/react": "workspace:*", + "react": "catalog:", + }, + "optionalPeers": [ + "@executor-js/react", + "react", + ], + }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@executor-js/sdk": "workspace:*", "@napi-rs/keyring": "^1.2.0", - "effect": "catalog:", }, "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "vitest": "catalog:", }, + "peerDependencies": { + "effect": "catalog:", + }, }, "packages/plugins/mcp": { "name": "@executor-js/plugin-mcp", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", - "effect": "catalog:", "zod": "^4.3.6", }, "devDependencies": { @@ -840,6 +935,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:", @@ -849,6 +945,7 @@ "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "@tanstack/react-router": "catalog:", + "effect": "catalog:", "react": "catalog:", }, "optionalPeers": [ @@ -861,13 +958,12 @@ }, "packages/plugins/onepassword": { "name": "@executor-js/plugin-onepassword", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@1password/op-js": "^0.1.13", "@1password/sdk": "^0.4.1-beta.1", "@effect/atom-react": "catalog:", "@executor-js/sdk": "workspace:*", - "effect": "catalog:", }, "devDependencies": { "@effect/vitest": "catalog:", @@ -875,6 +971,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:", @@ -883,6 +980,7 @@ "@effect/atom-react": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", + "effect": "catalog:", "react": ">=18", }, "optionalPeers": [ @@ -894,12 +992,11 @@ }, "packages/plugins/openapi": { "name": "@executor-js/plugin-openapi", - "version": "1.4.33", + "version": "1.5.2", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", - "effect": "catalog:", "lucide-react": "^1.7.0", "openapi-types": "^12.1.3", "yaml": "^2.7.1", @@ -912,6 +1009,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:", @@ -921,6 +1019,7 @@ "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", "@tanstack/react-router": "catalog:", + "effect": "catalog:", "react": "catalog:", }, "optionalPeers": [ @@ -937,18 +1036,19 @@ "dependencies": { "@executor-js/sdk": "workspace:*", "@workos-inc/node": "^8.11.1", - "effect": "catalog:", }, "devDependencies": { "@effect/vitest": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", + "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", "vitest": "catalog:", }, "peerDependencies": { "@executor-js/react": "workspace:*", + "effect": "catalog:", "react": ">=18", }, "optionalPeers": [ @@ -958,7 +1058,7 @@ }, "packages/react": { "name": "@executor-js/react", - "version": "1.4.21", + "version": "1.4.24", "dependencies": { "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", @@ -1216,6 +1316,8 @@ "@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="], + "@changesets/changelog-github": ["@changesets/changelog-github@0.7.0", "", { "dependencies": { "@changesets/get-github-info": "^0.8.0", "@changesets/types": "^6.1.0", "dotenv": "^8.1.0" } }, "sha512-rBsbRvc4TVn+FvFnOVM3LxlFJfTXXCp8gfVJ+0BubxWNSVnLuAzowi5j+IEraLLP52w8AAs9QfKbPS3MMiXQJA=="], + "@changesets/cli": ["@changesets/cli@2.30.0", "", { "dependencies": { "@changesets/apply-release-plan": "^7.1.0", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.3", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.15", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-5D3Nk2JPqMI1wK25pEymeWRSlSMdo5QOGlyfrKg0AOufrUcjEE3RQgaCpHoBiM31CSNrtSgdJ0U6zL1rLDDfBA=="], "@changesets/config": ["@changesets/config@3.1.3", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw=="], @@ -1224,6 +1326,8 @@ "@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ=="], + "@changesets/get-github-info": ["@changesets/get-github-info@0.8.0", "", { "dependencies": { "dataloader": "^1.4.0", "node-fetch": "^2.5.0" } }, "sha512-cRnC+xdF0JIik7coko3iUP9qbnfi1iJQ3sAa6dE+Tx3+ET8bjFEm63PA4WEohgjYcmsOikPHWzPsMWWiZmntOQ=="], + "@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.15", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.3", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.7", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g=="], "@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="], @@ -1460,6 +1564,8 @@ "@executor-js/desktop": ["@executor-js/desktop@workspace:apps/desktop"], + "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], + "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], "@executor-js/example-docs-sdk-quickstart": ["@executor-js/example-docs-sdk-quickstart@workspace:examples/docs-sdk-quickstart"], @@ -1492,6 +1598,10 @@ "@executor-js/plugin-graphql": ["@executor-js/plugin-graphql@workspace:packages/plugins/graphql"], + "@executor-js/plugin-graphql-greenfield": ["@executor-js/plugin-graphql-greenfield@workspace:packages/plugins/graphql-greenfield"], + + "@executor-js/plugin-http-source": ["@executor-js/plugin-http-source@workspace:packages/plugins/http-source"], + "@executor-js/plugin-keychain": ["@executor-js/plugin-keychain@workspace:packages/plugins/keychain"], "@executor-js/plugin-mcp": ["@executor-js/plugin-mcp@workspace:packages/plugins/mcp"], @@ -1664,6 +1774,16 @@ "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@kitlangton/terminal-control": ["@kitlangton/terminal-control@0.3.0", "", { "optionalDependencies": { "@kitlangton/terminal-control-darwin-arm64": "0.3.0", "@kitlangton/terminal-control-darwin-x64": "0.3.0", "@kitlangton/terminal-control-linux-arm64-gnu": "0.3.0", "@kitlangton/terminal-control-linux-x64-gnu": "0.3.0" }, "peerDependencies": { "vitest": ">=3.0.0" }, "optionalPeers": ["vitest"] }, "sha512-YlOx9ztDInHBETgxWNRd6nq/YzpMlw1YEJ1OIrhdeWTqrQdJHIYThWGiWda9GxmQAThWNULkKR17as+QDgyKdQ=="], + + "@kitlangton/terminal-control-darwin-arm64": ["@kitlangton/terminal-control-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "termctrl": "bin/termctrl" } }, "sha512-HydPv5if5pAjwHNbA7TnZAJYKfq9AiZITaJuFyF1HsUMedzin3qzv9WRVBqIiGsQ/FiR1i1kaGhyLzaVpZR35w=="], + + "@kitlangton/terminal-control-darwin-x64": ["@kitlangton/terminal-control-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64", "bin": { "termctrl": "bin/termctrl" } }, "sha512-jrCSlJybh9NkpAXKdvP6p4HpMDQLtUki94V6dzKMoRh/PSTufgPQ66g8HphwjxUoiB8aTPcXgN2iBfPxOFpvqA=="], + + "@kitlangton/terminal-control-linux-arm64-gnu": ["@kitlangton/terminal-control-linux-arm64-gnu@0.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "termctrl": "bin/termctrl" } }, "sha512-hwtLlpeRCBESveYl0Z8w6TphgAU7AQH9FSKCpwYKDL/n/hECW5kQ3zEuy7uULnPmtk7vwVlBXRTJKP553OUCXg=="], + + "@kitlangton/terminal-control-linux-x64-gnu": ["@kitlangton/terminal-control-linux-x64-gnu@0.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "termctrl": "bin/termctrl" } }, "sha512-ge62a1I67X+aKX9VcMcEY92JHMQ3oxWS3ot3z85sKYEb3kse7q/hEtpgBYldYCBLxvu57U/Fa8sC6C3l5+xA5w=="], + "@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="], "@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="], @@ -1986,8 +2106,6 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], - "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -3058,6 +3176,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -3144,7 +3264,7 @@ "dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], - "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], @@ -3814,7 +3934,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -3982,6 +4102,8 @@ "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], + "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], @@ -4024,7 +4146,7 @@ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -4738,6 +4860,8 @@ "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -4892,12 +5016,16 @@ "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -5096,6 +5224,8 @@ "@lobehub/ui/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "@lobehub/ui/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], @@ -5290,6 +5420,8 @@ "atmn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "atmn/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "better-auth/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -5424,6 +5556,8 @@ "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], + "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], + "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "node-gyp/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], @@ -5500,6 +5634,8 @@ "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], + "streamdown/marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -6158,6 +6294,8 @@ "@inquirer/core/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "@libsql/kysely-libsql/@libsql/client/@libsql/hrana-client/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.3.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rmOqsLcDI65zzxlUOoEiPJLhqmbFsZF6p4UJQ2kMqB+Kc0Rt5/A1OAdOZ/Wo8fQfJWjR1IbkbpEINFioyKf+nQ=="], "@libsql/kysely-libsql/@libsql/client/libsql/@libsql/darwin-x64": ["@libsql/darwin-x64@0.3.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-q9O55B646zU+644SMmOQL3FIfpmEvdWpRpzubwFc2trsa+zoBlSkHuzU9v/C+UNoPHQVRMP7KQctJ455I/h/xw=="], diff --git a/docs/sdk/quickstart.mdx b/docs/sdk/quickstart.mdx index 2305296a7..61ec01b27 100644 --- a/docs/sdk/quickstart.mdx +++ b/docs/sdk/quickstart.mdx @@ -4,7 +4,8 @@ description: Create an Executor, add a source, and inspect its tools. --- import CreateExecutor from "/snippets/sdk/quickstart/create-executor.mdx"; -import AddSource from "/snippets/sdk/quickstart/add-source.mdx"; +import AddIntegration from "/snippets/sdk/quickstart/add-integration.mdx"; +import CreateConnection from "/snippets/sdk/quickstart/create-connection.mdx"; import ListTools from "/snippets/sdk/quickstart/list-tools.mdx"; import InspectSchema from "/snippets/sdk/quickstart/inspect-schema.mdx"; import CloseExecutor from "/snippets/sdk/quickstart/close-executor.mdx"; @@ -13,7 +14,7 @@ import CloseExecutor from "/snippets/sdk/quickstart/close-executor.mdx"; The SDK lets you embed Executor in a TypeScript application. -This quickstart creates an Executor, loads an OpenAPI source from a local spec, lists the tools it contributed, and inspects one tool schema. The snippets on this page are generated from a runnable example in `examples/docs-sdk-quickstart`. +This quickstart creates an Executor, adds an OpenAPI integration from a local spec, connects a credential so the integration's tools appear, lists those tools, and inspects one tool schema. The snippets on this page are generated from a runnable example in `examples/docs-sdk-quickstart`. ## Install @@ -45,15 +46,23 @@ Import the SDK and configure the plugins you want to expose. `onElicitation: "accept-all"` is convenient for scripts and examples. Applications with interactive approval flows should pass a handler instead. -## Add A Source +The `providers` you pass register where credential values are stored. A writable provider is required before you can create a connection with an inline value; this example uses a tiny in-memory store. -Sources add tools to the Executor catalog. This example registers an OpenAPI document under the `inventory` namespace. +## Add An Integration - +An integration is the API surface. This example registers an OpenAPI document under the `inventory` slug and declares an apiKey authentication template — `variable("token")` marks where a connection's credential is placed on each request. + + + +## Create A Connection + +Tools are produced per connection. A connection is the saved credential for one integration; creating one with an inline `value` writes it to the default writable provider and yields the integration's tools. + + ## List Tools -After a source is registered, its tools appear in the unified tool catalog. +After a connection is created, its tools appear in the unified tool catalog, keyed by `address`. diff --git a/docs/self-hosting/guide.mdx b/docs/self-hosting/guide.mdx index b114a986b..35bf9d3d0 100644 --- a/docs/self-hosting/guide.mdx +++ b/docs/self-hosting/guide.mdx @@ -5,22 +5,39 @@ description: Run Executor on your own infrastructure in a single container. Executor self-hosts as **one container** — the database (SQLite/libSQL), the QuickJS code sandbox, and the MCP server all run in-process. There is no separate -database, worker, or proxy to operate, and no required configuration: a bare -`docker compose up` boots a working instance and walks you through creating the -admin account in the browser. +database, worker, or proxy to operate, and no required configuration: a published +image or a bare `docker compose up` boots a working instance and walks you +through creating the admin account in the browser. ## Quick start -From a clone of the repository: +Using the published GHCR image: ```bash -cd apps/host-selfhost -docker compose up -d --build +docker run -d \ + --name executor-selfhost \ + -p 4788:4788 \ + -v executor-data:/data \ + ghcr.io/rhyssullivan/executor-selfhost:latest ``` Then open [http://localhost:4788](http://localhost:4788). On a fresh instance you'll see a **setup screen** — create the first admin account, and you're in. +Published images are tagged as: + +- `ghcr.io/rhyssullivan/executor-selfhost:latest` for the latest stable release +- `ghcr.io/rhyssullivan/executor-selfhost:beta` for the latest prerelease +- `ghcr.io/rhyssullivan/executor-selfhost:vX.Y.Z` for a pinned release tag +- `ghcr.io/rhyssullivan/executor-selfhost:X.Y.Z` for the same pinned version without `v` + +From a clone of the repository: + +```bash +cd apps/host-selfhost +docker compose up -d --build +``` + That's the whole install. The container persists its data (database and generated keys) in the `executor-data` volume, so it survives restarts and upgrades. @@ -107,6 +124,20 @@ keys as well as your data. ## Upgrading +If you use the published image: + +```bash +docker pull ghcr.io/rhyssullivan/executor-selfhost:latest +docker rm -f executor-selfhost +docker run -d \ + --name executor-selfhost \ + -p 4788:4788 \ + -v executor-data:/data \ + ghcr.io/rhyssullivan/executor-selfhost:latest +``` + +If you build from source: + ```bash cd apps/host-selfhost git pull diff --git a/docs/snippets/sdk/quickstart/add-integration.mdx b/docs/snippets/sdk/quickstart/add-integration.mdx new file mode 100644 index 000000000..4a88e8baf --- /dev/null +++ b/docs/snippets/sdk/quickstart/add-integration.mdx @@ -0,0 +1,26 @@ +{/_ +This file is generated by scripts/generate-doc-snippets.ts. +Edit examples/docs-sdk-quickstart/src/main.ts between the docs:start/docs:end markers instead. +_/} + +```ts +// An integration is the API surface. The apiKey template declares where a +// connection's credential is placed on each request — here, an `X-API-Key` +// header. `variable("token")` is the slot the resolved credential renders into. +await executor.openapi.addSpec({ + slug: "inventory", + description: "Inventory API", + baseUrl: "https://inventory.example.com", + spec: { + kind: "blob", + value: JSON.stringify(inventoryApi), + }, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "X-API-Key": [variable("token")] }, + }, + ], +}); +``` diff --git a/docs/snippets/sdk/quickstart/add-source.mdx b/docs/snippets/sdk/quickstart/add-source.mdx deleted file mode 100644 index 2d004ee75..000000000 --- a/docs/snippets/sdk/quickstart/add-source.mdx +++ /dev/null @@ -1,12 +0,0 @@ -{/_ -This file is generated by scripts/generate-doc-snippets.ts. -Edit examples/docs-sdk-quickstart/src/main.ts between the docs:start/docs:end markers instead. -_/} - -```ts -await executor.openapi.addSpec({ - namespace: "inventory", - scope: "docs-workspace", - spec: JSON.stringify(inventoryApi), -}); -``` diff --git a/docs/snippets/sdk/quickstart/create-connection.mdx b/docs/snippets/sdk/quickstart/create-connection.mdx new file mode 100644 index 000000000..9715f7e65 --- /dev/null +++ b/docs/snippets/sdk/quickstart/create-connection.mdx @@ -0,0 +1,17 @@ +{/_ +This file is generated by scripts/generate-doc-snippets.ts. +Edit examples/docs-sdk-quickstart/src/main.ts between the docs:start/docs:end markers instead. +_/} + +```ts +// Tools are produced per connection. A connection is the saved credential for +// one integration; creating one with an inline `value` writes it to the default +// writable provider and yields the integration's tools. +await executor.connections.create({ + owner: "org", + name: "default", + integration: "inventory", + template: "apiKey", + value: "inventory-api-key", +}); +``` diff --git a/docs/snippets/sdk/quickstart/create-executor.mdx b/docs/snippets/sdk/quickstart/create-executor.mdx index c9717176e..183857d9c 100644 --- a/docs/snippets/sdk/quickstart/create-executor.mdx +++ b/docs/snippets/sdk/quickstart/create-executor.mdx @@ -4,9 +4,24 @@ Edit examples/docs-sdk-quickstart/src/main.ts between the docs:start/docs:end ma _/} ```ts +// A connection stores its value in a writable credential provider. This tiny +// in-memory store is enough for a script; production hosts swap in a durable +// provider (keychain, 1Password, an encrypted DB store, …). Providers are +// Effect-native, so `get`/`set` return `Effect`s. +const memory = new Map(); +const memoryProvider: CredentialProvider = { + key: ProviderKey.make("memory"), + writable: true, + get: (id: ProviderItemId) => Effect.sync(() => memory.get(String(id)) ?? null), + set: (id: ProviderItemId, value: string) => + Effect.sync(() => { + memory.set(String(id), value); + }), +}; + const executor = await createExecutor({ - scopes: [{ id: "docs-workspace", name: "Docs Workspace" }], plugins: [openApiPlugin()], + providers: [memoryProvider], onElicitation: "accept-all", }); ``` diff --git a/docs/snippets/sdk/quickstart/inspect-schema.mdx b/docs/snippets/sdk/quickstart/inspect-schema.mdx index 7409b681c..96877f29e 100644 --- a/docs/snippets/sdk/quickstart/inspect-schema.mdx +++ b/docs/snippets/sdk/quickstart/inspect-schema.mdx @@ -4,7 +4,8 @@ Edit examples/docs-sdk-quickstart/src/main.ts between the docs:start/docs:end ma _/} ```ts -const schema = await executor.tools.schema("inventory.listItems"); +const firstAddress = tools[0]?.address; +const schema = firstAddress ? await executor.tools.schema(firstAddress) : null; console.log(schema?.inputTypeScript ?? "No input required"); ``` diff --git a/docs/snippets/sdk/quickstart/list-tools.mdx b/docs/snippets/sdk/quickstart/list-tools.mdx index 3857085d0..2eb6903fb 100644 --- a/docs/snippets/sdk/quickstart/list-tools.mdx +++ b/docs/snippets/sdk/quickstart/list-tools.mdx @@ -4,9 +4,9 @@ Edit examples/docs-sdk-quickstart/src/main.ts between the docs:start/docs:end ma _/} ```ts -const tools = await executor.tools.list({ sourceId: "inventory" }); +const tools = await executor.tools.list({ integration: "inventory" }); for (const tool of tools) { - console.log(`${tool.id}: ${tool.description}`); + console.log(`${tool.address}: ${tool.description}`); } ``` diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md new file mode 100644 index 000000000..9acc25230 --- /dev/null +++ b/e2e/AGENTS.md @@ -0,0 +1,121 @@ +# Writing e2e scenarios + +A scenario is ONE user-meaningful product journey, written once against the +`Target` interface and run on every deployment that supports its capabilities. +Tests are **black-box**: drive the product only through public surfaces (typed +API, web UI, MCP, CLI). Never import app internals, never poke the DB, never +modify product code or stubs — if the product or stub blocks you, STOP and +report the blocker instead of working around it. + +**The test source is the review artifact.** A reviewer judges correctness by +reading the test; write it so it reads as a spec. Assertions are plain vitest +`expect` (use the message argument for intent). Browser runs additionally +produce a Playwright trace, video, and step screenshots for debugging. + +## File placement + +- `scenarios/*.test.ts` — runs on every target (cloud + selfhost) +- `cloud/*.test.ts` — cloud-only (e.g. billing, WorkOS-session UI) +- `selfhost/*.test.ts` — selfhost-only + +## Anatomy + +```ts +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { scenario } from "../src/scenario"; + +const coreApi = composePluginApi([] as const); // tools/integrations/connections/providers/executions/oauth/policies + +scenario("Tools · a fresh workspace advertises the built-in tools", { needs: ["api"] }, (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); // fresh isolated user+org + const client = yield* ctx.api.client(coreApi, identity); // typed HttpApiClient + const tools = yield* client.tools.list(); + expect(tools.length, "at least one tool is exposed").toBeGreaterThan(0); + }), +); +``` + +- Capabilities (`needs`): `api`, `browser` (cloud only today), `mcp-oauth` + (selfhost only today), `billing` (cloud only). +- Resources created in a test must be cleaned up with `Effect.ensuring` (a + finalizer), not trailing statements — a mid-test failure must not leak state + into the shared instance. + +## Browser scenarios (cloud) + +```ts +const identity = yield * ctx.target.newIdentity(); // logged in, has an org +// or newIdentity({ org: false }) for the onboarding flow +yield * + ctx.browser.session(identity, async ({ page, step }) => { + await step("A fresh user lands on the integrations page", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByText("Integrations").first().waitFor(); + }); + }); +``` + +- `step(label, fn)` names a Playwright trace group and saves a screenshot — + label steps as user actions ("Open the org switcher"), not selectors. +- The session records video (mp4) + a full Playwright trace into the run's + artifact dir; a failure saves `failure.png` automatically. +- Prefer role-based locators (`getByRole("menuitem", ...)`) — text locators + often match the look-alike trigger button in the bottom bar. +- After an action that navigates, wait for the URL/network to settle before + opening menus: `await page.waitForLoadState("networkidle")`. +- The stub user renders as "Test User" / `test@example.com`. + +## MCP scenarios (selfhost) + +```ts +const session = ctx.mcp.session(identity); +const tools = yield * session.listTools(); // OAuth happens headlessly here +const r = yield * session.call("execute", { code: "return 1 + 1;" }); +// human-in-the-loop: session.approvePaused(r.text) resumes a paused execution +``` + +## Running + +```sh +cd e2e +bun run test # boots both dev servers, runs everything +bun run test:cloud # one target +# attach to an already-running server while iterating: +E2E_CLOUD_URL=http://127.0.0.1:4798 ../node_modules/.bin/vitest run --project cloud +E2E_SELFHOST_URL=http://localhost:4799 ../node_modules/.bin/vitest run --project selfhost +``` + +Each run writes `runs///result.json` plus any browser artifacts +(trace.zip / session.mp4 / screenshots). `bun run serve` hosts the scenario × +target matrix; a run page links the trace into Playwright's trace viewer. + +## Discovering endpoints + +- The full OpenAPI spec: `curl http://127.0.0.1:4798/api/openapi.json` (cloud). +- The typed client mirrors it: `client..(...)` with groups + tools/integrations/connections/providers/executions/oauth/policies. +- To see payload shapes, read the API definitions under + `packages/core/api/src//api.ts` (READ ONLY — for shapes, not imports). + +## Isolation rules + +- Cloud: `newIdentity()` is a fresh user+org — you are isolated for free. +- Selfhost: everyone is the bootstrap admin. PREFIX every resource you create + with your scenario slug (e.g. policy pattern `policies-scn.*`) so parallel + scenarios don't collide, and don't assert on global counts (assert "contains + mine", not "length is 1"). + +## Quality bar + +- The scenario name reads like a product guarantee ("Billing · the free plan + stops organization creation after 3"), not a test id. +- The test reads as a spec top-to-bottom; a reviewer should understand the + journey and the guarantee without running it. +- Assert outcomes the user cares about, not implementation details. No + tautologies (don't assert what the setup already guarantees). Assert on + values, not booleans — `expect(list).toContain(x)`, never + `expect(list.includes(x)).toBe(true)` — so failures show the data. +- Keep it deterministic: no sleeps; wait on conditions. diff --git a/e2e/CHANGELOG.md b/e2e/CHANGELOG.md new file mode 100644 index 000000000..ea21cd234 --- /dev/null +++ b/e2e/CHANGELOG.md @@ -0,0 +1,12 @@ +# @executor-js/e2e + +## 0.0.1 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/plugin-graphql@1.5.2 + - @executor-js/plugin-mcp@1.5.2 + - @executor-js/plugin-openapi@1.5.2 + - @executor-js/api@1.4.24 diff --git a/e2e/cloud/connect-panel.test.ts b/e2e/cloud/connect-panel.test.ts new file mode 100644 index 000000000..da0ce21c8 --- /dev/null +++ b/e2e/cloud/connect-panel.test.ts @@ -0,0 +1,49 @@ +// Cloud-specific (browser): the agent-connect panel defaults to Remote HTTP +// with an org-scoped /mcp URL, and the Standard I/O tab switches the install +// command. Driven through the Integrations page as a fresh user with an org. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +scenario( + "Connect · the agent-connect panel gives working copy for both transports", + { needs: ["browser"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + + yield* ctx.browser.session(identity, async ({ page, step }) => { + await step("Open the Integrations page", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByText("Connect an agent").first().waitFor(); + }); + + const command = () => page.locator("code").first().innerText(); + + await step("Remote HTTP is the default transport", async () => { + await page.getByText("Connect an agent").first().waitFor(); + }); + const httpCommand = await command(); + expect(httpCommand, "the default command adds the MCP server").toContain("npx add-mcp"); + expect(httpCommand, "the HTTP command is org-scoped").toMatch(/\/org_[^/]+\/mcp/); + expect(httpCommand).toContain("--transport http"); + + await step("Switch to Standard I/O", async () => { + await page.getByRole("tab", { name: "Standard I/O" }).click(); + await page.waitForLoadState("networkidle"); + }); + const stdioCommand = await command(); + expect(stdioCommand, "the command changed for stdio").not.toBe(httpCommand); + expect(stdioCommand, "stdio does not use the HTTP transport").not.toContain( + "--transport http", + ); + + await step("Switch back to Remote HTTP", async () => { + await page.getByRole("tab", { name: "Remote HTTP" }).click(); + await page.waitForLoadState("networkidle"); + }); + expect(await command(), "the HTTP command is restored").toContain("--transport http"); + }); + }), +); diff --git a/e2e/cloud/onboarding-mcp-url.test.ts b/e2e/cloud/onboarding-mcp-url.test.ts new file mode 100644 index 000000000..995c25d57 --- /dev/null +++ b/e2e/cloud/onboarding-mcp-url.test.ts @@ -0,0 +1,58 @@ +// Cloud-specific (browser): the onboarding MCP-setup step gives the user an +// org-scoped MCP server URL and a matching install command. Driven through the +// real web UI as a fresh user who has no organization yet. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +scenario( + "Onboarding · the MCP setup step hands the user their org-scoped MCP server URL", + { needs: ["browser"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity({ org: false }); + + yield* ctx.browser.session(identity, async ({ page, step }) => { + await step( + "A fresh user without an org lands on the create-org onboarding page", + async () => { + await page.goto("/", { waitUntil: "networkidle" }); + // Step 1 of 2 — the org-name input is the landmark that proves we're on onboarding. + await page.getByPlaceholder("Northwind Labs").waitFor(); + }, + ); + + await step("Create an organization to advance to the MCP setup step", async () => { + await page.getByPlaceholder("Northwind Labs").fill("Test Org"); + await page.getByRole("button", { name: "Create organization" }).click(); + // Successful creation navigates to the 'Connect your MCP client' step. + await page.getByText("Connect your MCP client").waitFor(); + }); + + await step("Read the MCP server URL displayed on the setup page", async () => { + const urlSection = page.getByRole("region", { name: "MCP server URL" }); + await urlSection.waitFor(); + // Wait until the endpoint is populated (the component defers origin to useEffect). + await page.waitForFunction(() => { + const section = document.querySelector('[aria-label="MCP server URL"]'); + const span = section?.querySelector("span.font-mono"); + return span && span.textContent !== "…" && span.textContent !== ""; + }); + }); + + const mcpUrlSection = page.getByRole("region", { name: "MCP server URL" }); + const mcpUrl = await mcpUrlSection.locator("span.font-mono").innerText(); + expect(mcpUrl, "MCP URL is org-scoped").toMatch(/\/org_[^/]+\/mcp/); + + const installSection = page.getByRole("region", { name: "Install command" }); + await installSection.waitFor(); + const installCommand = await installSection.locator("code").innerText(); + + // The install command must reference the SAME org as the displayed URL + // — not a different one or a bare /mcp path. + const orgId = /\/(org_[^/]+)\/mcp/.exec(mcpUrl)?.[1] ?? "(no org segment in MCP URL)"; + expect(installCommand, "the install command references the same org").toContain(orgId); + }); + }), +); diff --git a/e2e/cloud/org-limit.test.ts b/e2e/cloud/org-limit.test.ts new file mode 100644 index 000000000..1b8c65d94 --- /dev/null +++ b/e2e/cloud/org-limit.test.ts @@ -0,0 +1,89 @@ +// Cloud-specific (billing): the free plan allows 3 organizations per user. +// Driven ENTIRELY through the real web UI as a fresh user — the onboarding +// create-org page for the first org, then the in-app account-menu → +// org-switcher → "Create organization" modal for the rest. The run's +// Playwright trace + video + step screenshots are the debugging artifacts. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +const FREE_LIMIT = 3; + +scenario( + "Billing · the free plan stops organization creation after 3", + { needs: ["browser", "billing"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity({ org: false }); + + yield* ctx.browser.session(identity, async ({ page, step }) => { + await step("A fresh user lands on onboarding (no organization yet)", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByPlaceholder("Northwind Labs").waitFor(); + }); + + await step(`Create "Acme 1" (1 of ${FREE_LIMIT} allowed on the free plan)`, async () => { + await page.getByPlaceholder("Northwind Labs").fill("Acme 1"); + await page.getByRole("button", { name: "Create organization" }).click(); + // Onboarding step 2 — proves the first org was created. + await page.getByText("Connect your MCP client").waitFor(); + }); + + await step("Continue into the app", async () => { + await page.getByRole("button", { name: "Continue to app" }).click(); + await page.getByText("Integrations").first().waitFor(); + // Let the router navigation fully settle (slow on a cold dev server) + // before opening menus — a late remount closes them mid-interaction. + await page.waitForURL(/\/$/, { timeout: 30_000 }); + await page.waitForLoadState("networkidle"); + }); + + const openCreateOrgModal = async (currentOrg: string) => { + await page.getByRole("button", { name: /Test User/ }).click(); + // The org entry + "Create organization" are radix menu items; role + // locators keep us off the look-alike trigger button beneath. + await page.getByRole("menuitem", { name: currentOrg }).click(); + await page.getByRole("menuitem", { name: "Create organization" }).click(); + await page.getByText("Add another organization").waitFor(); + }; + + for (let i = 2; i <= FREE_LIMIT; i++) { + await step(`Open the org switcher and choose "Create organization"`, async () => { + await openCreateOrgModal(`Acme ${i - 1}`); + }); + await step(`Create "Acme ${i}" (${i} of ${FREE_LIMIT})`, async () => { + await page.getByPlaceholder("Northwind Labs").fill(`Acme ${i}`); + await page.getByRole("button", { name: "Create organization" }).click(); + // The modal closes and the session switches into the new org. + await page.getByText("Add another organization").waitFor({ state: "hidden" }); + await page.getByRole("button", { name: new RegExp(`Acme ${i}`) }).waitFor(); + }); + } + + await step("Attempt a 4th organization (over the free limit)", async () => { + await openCreateOrgModal(`Acme ${FREE_LIMIT}`); + await page.getByPlaceholder("Northwind Labs").fill("Acme 4"); + await page.getByRole("button", { name: "Create organization" }).click(); + await page.locator("p.text-destructive").first().waitFor(); + }); + + const errorText = await page.locator("p.text-destructive").first().innerText(); + expect(errorText.length, "the UI shows a visible refusal").toBeGreaterThan(0); + + // Cross-check through the session API, with the browser's own session + // cookie (fetched explicitly — the Secure cookie isn't replayed by + // page.request over plain http). + const cookie = (await page.context().cookies()) + .map((c) => `${c.name}=${c.value}`) + .join("; "); + const response = await fetch(new URL("/api/auth/organizations", ctx.target.baseUrl), { + headers: { cookie }, + }); + const body = (await response.json()) as { organizations: ReadonlyArray<{ name: string }> }; + expect(body.organizations.length, "exactly the free-plan allowance exists").toBe( + FREE_LIMIT, + ); + }); + }), +); diff --git a/e2e/cloud/org-switcher.test.ts b/e2e/cloud/org-switcher.test.ts new file mode 100644 index 000000000..5f276008e --- /dev/null +++ b/e2e/cloud/org-switcher.test.ts @@ -0,0 +1,131 @@ +// Cloud-specific (browser): switching organizations changes the active workspace. +// A fresh user creates two organizations through the real web UI — the first +// via onboarding and the second via the account-menu → org switcher → "Create +// organization" modal — then uses the same switcher to return to the first org +// and confirms the workspace label in the bottom-left account button updates. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +scenario( + "Organizations · switching organizations switches the workspace", + { needs: ["browser"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity({ org: false }); + + yield* ctx.browser.session(identity, async ({ page, step }) => { + // ── Step 1: onboarding, create the first org ───────────────────── + await step("Fresh user lands on onboarding (no organization yet)", async () => { + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByPlaceholder("Northwind Labs").waitFor(); + }); + + const ORG_1 = "Switcher Org One"; + const ORG_2 = "Switcher Org Two"; + + await step(`Create "${ORG_1}" via onboarding`, async () => { + await page.getByPlaceholder("Northwind Labs").fill(ORG_1); + await page.getByRole("button", { name: "Create organization" }).click(); + // Onboarding step 2 — proves the first org was created. + await page.getByText("Connect your MCP client").waitFor(); + }); + + await step("Continue into the app", async () => { + await page.getByRole("button", { name: "Continue to app" }).click(); + await page.getByText("Integrations").first().waitFor(); + // Let the router navigation fully settle before opening menus — a late + // remount closes them mid-interaction. + await page.waitForURL(/\/$/, { timeout: 30_000 }); + await page.waitForLoadState("networkidle"); + }); + + // ── Step 2: create the second org via the account-menu switcher ── + await step('Open the org switcher and choose "Create organization"', async () => { + await page.getByRole("button", { name: /Test User/ }).click(); + await page.getByRole("menuitem", { name: ORG_1 }).click(); + await page.getByRole("menuitem", { name: "Create organization" }).click(); + await page.getByText("Add another organization").waitFor(); + }); + + await step(`Create "${ORG_2}" via the org switcher modal`, async () => { + await page.getByPlaceholder("Northwind Labs").fill(ORG_2); + await page.getByRole("button", { name: "Create organization" }).click(); + // The modal closes and the session switches into the new org. + await page.getByText("Add another organization").waitFor({ state: "hidden" }); + // Confirm the account button now shows ORG_2. + await page.getByRole("button", { name: new RegExp(ORG_2) }).waitFor(); + }); + + // Capture the label while we are in ORG_2 as a baseline. + const labelAfterOrg2 = await page + .getByRole("button", { name: new RegExp(ORG_2) }) + .innerText(); + expect(labelAfterOrg2, "account button shows the second org after creation").toContain( + ORG_2, + ); + + // ── Step 3: switch back to the first org ───────────────────────── + // The org-switcher sub-menu shows org IDs (not names) because the stub's + // getOrganization returns the ID as the name. The currently-active org is + // rendered with data-disabled="" (Radix convention). The only item without + // data-disabled that isn't "Create organization" is ORG_1. + await step(`Open the org switcher and switch back to "${ORG_1}"`, async () => { + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: /Test User/ }).click(); + // Click the SubTrigger (shows current org name = ORG_2) to expand the list. + await page.getByRole("menuitem", { name: ORG_2 }).click(); + // Wait for the sub-content to open. + await page + .locator('[data-slot="dropdown-menu-sub-content"]') + .waitFor({ state: "visible" }); + // The organizationsAtom loads asynchronously — wait until the loading state + // clears and the org items appear. The org items have data-disabled="" when + // active and no data-disabled when not. "Create organization" is always shown + // and always enabled; wait until there are at least 2 non-disabled items + // (the non-active org + "Create organization") before clicking. + await page + .locator('[data-slot="dropdown-menu-sub-content"]') + .locator('[role="menuitem"]:not([data-disabled])') + .nth(1) + .waitFor(); + // Now the sub-content has loaded. The org items appear BEFORE the separator and + // "Create organization". ORG_1 (non-active, not disabled) appears before ORG_2 + // (active, disabled) and before "Create organization". Click the first + // non-disabled item that is NOT "Create organization" — that is ORG_1. + await page + .locator('[data-slot="dropdown-menu-sub-content"]') + .locator('[role="menuitem"]:not([data-disabled])') + .filter({ hasNot: page.getByText("Create organization") }) + .first() + .click(); + // The menu closes, the page reloads, and the session switches into ORG_1. + await page.getByRole("button", { name: new RegExp(ORG_1) }).waitFor(); + }); + + // ── Assert: workspace label reflects the first org ─────────────── + const labelAfterSwitch = await page + .getByRole("button", { name: new RegExp(ORG_1) }) + .innerText(); + expect( + labelAfterSwitch, + "account button shows the first org after switching back", + ).toContain(ORG_1); + + // Cross-check the active org through the session API. + const cookie = (await page.context().cookies()) + .map((c) => `${c.name}=${c.value}`) + .join("; "); + const response = await fetch(new URL("/api/auth/organizations", ctx.target.baseUrl), { + headers: { cookie }, + }); + const body = (await response.json()) as { + organizations: ReadonlyArray<{ name: string }>; + activeOrganizationId?: string; + }; + expect(response.ok).toBe(true); + expect(body.organizations.length, "exactly two organizations exist for this user").toBe(2); + }); + }), +); diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..902b5f787 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,35 @@ +{ + "name": "@executor-js/e2e", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "test:cloud": "vitest run --project cloud", + "test:selfhost": "vitest run --project selfhost", + "test:watch": "vitest", + "viewer:build": "bun scripts/rebuild-viewer.ts", + "serve": "bun scripts/rebuild-viewer.ts && bun scripts/serve.ts" + }, + "dependencies": { + "@executor-js/api": "workspace:*", + "@executor-js/plugin-graphql": "workspace:*", + "@executor-js/plugin-mcp": "workspace:*", + "@executor-js/plugin-openapi": "workspace:*", + "@executor-js/sdk": "workspace:*", + "@kitlangton/terminal-control": "^0.3.0", + "effect": "catalog:", + "monaco-editor": "^0.55.1", + "playwright": "^1.60.0", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + } +} diff --git a/e2e/scenarios/api-tools.test.ts b/e2e/scenarios/api-tools.test.ts new file mode 100644 index 000000000..191d9d438 --- /dev/null +++ b/e2e/scenarios/api-tools.test.ts @@ -0,0 +1,28 @@ +// Cross-target: the typed API surface, exactly as a consumer uses it. The +// contract is the CORE executor HttpApi (composePluginApi([])) — every target +// serves it under /api, so one scenario runs against all of them. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; + +const coreApi = composePluginApi([] as const); + +scenario("API · typed client lists the available tools", { needs: ["api"] }, (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(coreApi, identity); + const tools = yield* client.tools.list(); + expect(tools.length, "at least one tool is exposed").toBeGreaterThan(0); + }), +); + +scenario("API · a fresh identity starts with zero connections", { needs: ["api"] }, (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(coreApi, identity); + const connections = yield* client.connections.list(); + expect(connections.length, "no connections leak across identities").toBe(0); + }), +); diff --git a/e2e/scenarios/auth-methods-ui.test.ts b/e2e/scenarios/auth-methods-ui.test.ts new file mode 100644 index 000000000..82dfcaea9 --- /dev/null +++ b/e2e/scenarios/auth-methods-ui.test.ts @@ -0,0 +1,77 @@ +// Cross-target (browser): the multi-method auth add flow, end to end through +// the real web UI against a live loopback MCP test server (the target's dev +// server probes it). A no-auth server gets an API key declared at add time — +// the case where the server advertises nothing but the user knows better. +// The session video + per-step screenshots are the artifact. +// +// The OAuth-detected and connect-modal variants are selfhost-only (see +// selfhost/auth-methods-ui.test.ts): the cloud dev harness (workerd) cannot +// shape-probe loopback servers and its vault stub takes no pasted credentials. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; + +import { scenario } from "../src/scenario"; + +scenario( + "Auth methods · the add flow declares an API key alongside the detected method", + { needs: ["browser"] }, + (ctx) => + Effect.scoped( + Effect.gen(function* () { + // An OPEN server: the probe connects without auth, so the method list + // seeds with the detected "no authentication" row. The server name is + // unique per run — the derived integration namespace must not collide + // on targets whose identities share one tenant (selfhost admin). + const server = yield* serveMcpServer(() => + makeGreetingMcpServer({ name: `open-mcp-${randomBytes(3).toString("hex")}` }), + ); + const identity = yield* ctx.target.newIdentity(); + + yield* ctx.browser.session(identity, async ({ page, step }) => { + await step("Open the add-MCP flow pointed at the server", async () => { + await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { + waitUntil: "networkidle", + }); + // The URL auto-probes (debounced); the method list appears once + // the probe lands. + await page.getByText("How does this server authenticate?").waitFor(); + }); + + await step("The probe seeded the detected method", async () => { + await page.getByText("Method 1 · Detected").waitFor(); + }); + + await step("Declare an API key method alongside it", async () => { + await page.getByRole("button", { name: "Add method" }).click(); + await page.getByText("Method 2").waitFor(); + // The new row opens on the API key editor with the standard + // Authorization-header placement prefilled. + const headerName = page.getByPlaceholder("Authorization").last(); + await headerName.waitFor(); + }); + + await step("Add the source with both methods", async () => { + await page.getByRole("button", { name: "Add source" }).click(); + // onComplete routes to the new integration's detail hub. + await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); + await page.getByText("Connections").first().waitFor(); + }); + + await step("The connect modal offers both methods", async () => { + await page.getByRole("button", { name: "Add connection" }).first().click(); + await page.getByRole("tab", { name: "No authentication" }).waitFor(); + await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); + }); + + const tabs = await page.getByRole("tab").allInnerTexts(); + expect(tabs.join(", "), "both declared methods are selectable").toContain( + "No authentication", + ); + expect(tabs.join(", ")).toContain("API key (Authorization)"); + }); + }), + ), +); diff --git a/e2e/scenarios/auth-methods.test.ts b/e2e/scenarios/auth-methods.test.ts new file mode 100644 index 000000000..5d2183272 --- /dev/null +++ b/e2e/scenarios/auth-methods.test.ts @@ -0,0 +1,277 @@ +// Cross-target: multi-method authentication — an integration declares SEVERAL +// auth methods and a connection picks one by template slug. Covers the model +// rework: MCP's slugged `authenticationTemplate` array, the merge-append +// configureAuth flow (a custom API key must never displace a detected OAuth +// method), declaring a method on a server that advertises none, and GraphQL's +// multi-method add. Entirely through the typed clients — no MCP server is +// dialed (registration and method configuration are catalog statements). +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; + +const api = composePluginApi([mcpHttpPlugin(), graphqlHttpPlugin()] as const); + +const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; + +// Registration never dials the endpoint, so a closed local port is fine. +const MCP_ENDPOINT = "http://127.0.0.1:59998/mcp"; + +scenario( + "Auth methods · an MCP server can declare OAuth and an API key side by side", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const slug = freshSlug("mcp-multiauth"); + + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Multi-auth MCP", + endpoint: MCP_ENDPOINT, + slug, + authenticationTemplate: [ + { kind: "oauth2" }, + { kind: "header", headerName: "X-Api-Key", prefix: "Bearer " }, + ], + }, + }); + + yield* Effect.gen(function* () { + const integration = yield* client.integrations.get({ + params: { slug: IntegrationSlug.make(slug) }, + }); + + // Both methods project into the catalog, slugged by kind, so the + // connect flow can offer either and a connection binds one by slug. + expect( + integration.authMethods.map((m) => ({ kind: m.kind, template: m.template })), + "the catalog lists both declared methods", + ).toEqual([ + { kind: "oauth", template: "oauth2" }, + { kind: "apikey", template: "header" }, + ]); + + const apiKey = integration.authMethods.find((m) => m.kind === "apikey"); + expect(apiKey?.placements, "the API key method carries its header placement").toEqual([ + { carrier: "header", name: "X-Api-Key", prefix: "Bearer " }, + ]); + }).pipe( + Effect.ensuring( + client.mcp + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Auth methods · adding an API key method keeps a detected OAuth method", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const slug = freshSlug("mcp-oauth-plus-key"); + + // The add flow registered what the probe detected: OAuth only. + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "OAuth MCP", + endpoint: MCP_ENDPOINT, + slug, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + + yield* Effect.gen(function* () { + // "+ Custom method" merge-appends — it must not displace OAuth. + const configured = yield* client.mcp.configureAuth({ + params: { slug: IntegrationSlug.make(slug) }, + payload: { + authenticationTemplate: [{ kind: "header", headerName: "X-Api-Key" }], + }, + }); + expect( + configured.authenticationTemplate.map((m) => m.kind), + "the declared set now holds both methods", + ).toEqual(["oauth2", "header"]); + expect( + configured.authenticationTemplate[1]?.slug, + "the custom method gets its own custom_ slug", + ).toMatch(/^custom_/); + + const integration = yield* client.integrations.get({ + params: { slug: IntegrationSlug.make(slug) }, + }); + expect( + integration.authMethods.map((m) => m.kind), + "the catalog offers OAuth and the API key", + ).toEqual(["oauth", "apikey"]); + }).pipe( + Effect.ensuring( + client.mcp + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Auth methods · a no-auth MCP server can declare an API key method later", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const slug = freshSlug("mcp-open-plus-key"); + + // No declared auth — the server advertises nothing. + yield* client.mcp.addServer({ + payload: { transport: "remote", name: "Open MCP", endpoint: MCP_ENDPOINT, slug }, + }); + + yield* Effect.gen(function* () { + const before = yield* client.integrations.get({ + params: { slug: IntegrationSlug.make(slug) }, + }); + expect( + before.authMethods.map((m) => m.kind), + "an open server starts with the no-auth method", + ).toEqual(["none"]); + + yield* client.mcp.configureAuth({ + params: { slug: IntegrationSlug.make(slug) }, + payload: { + authenticationTemplate: [ + { kind: "header", headerName: "Authorization", prefix: "Bearer " }, + ], + }, + }); + + const after = yield* client.integrations.get({ + params: { slug: IntegrationSlug.make(slug) }, + }); + expect( + after.authMethods.map((m) => m.kind), + "no-auth and the declared API key coexist", + ).toEqual(["none", "apikey"]); + }).pipe( + Effect.ensuring( + client.mcp + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Auth methods · an MCP server can declare a query-parameter token method (ui.sh shape)", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const slug = freshSlug("mcp-query-token"); + + // Servers like ui.sh authenticate via a `?token=` query parameter — not + // a header or OAuth. The MCP plugin must store/project that as an apikey + // method carrying a QUERY placement. + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Query-token MCP", + endpoint: MCP_ENDPOINT, + slug, + authenticationTemplate: [{ kind: "query", paramName: "token" }], + }, + }); + + yield* Effect.gen(function* () { + const integration = yield* client.integrations.get({ + params: { slug: IntegrationSlug.make(slug) }, + }); + expect( + integration.authMethods.map((m) => ({ kind: m.kind, placements: m.placements })), + "the catalog projects a query-placement apikey method", + ).toEqual([ + { kind: "apikey", placements: [{ carrier: "query", name: "token", prefix: "" }] }, + ]); + + // And the connect modal's "+ custom method" flow can ADD one via + // configureAuth (the path that returned "Failed to add method" before). + const configured = yield* client.mcp.configureAuth({ + params: { slug: IntegrationSlug.make(slug) }, + payload: { authenticationTemplate: [{ kind: "query", paramName: "apikey" }] }, + }); + expect( + configured.authenticationTemplate.map((m) => m.kind), + "the custom query method is appended, not rejected", + ).toEqual(["query", "query"]); + }).pipe( + Effect.ensuring( + client.mcp + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "Auth methods · a GraphQL source registers multiple auth methods at add time", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const slug = freshSlug("graphql-multiauth"); + + yield* client.graphql.addIntegration({ + payload: { + endpoint: "http://127.0.0.1:59998/graphql", + slug, + name: "Multi-auth GraphQL", + authenticationTemplate: [ + { kind: "apiKey", slug: "apiKey", in: "header", name: "X-Api-Key" }, + { kind: "apiKey", slug: "apikey-2", in: "query", name: "api_key" }, + ], + }, + }); + + yield* Effect.gen(function* () { + const integration = yield* client.integrations.get({ + params: { slug: IntegrationSlug.make(slug) }, + }); + expect( + integration.authMethods.map((m) => ({ template: m.template, kind: m.kind })), + "both declared methods are in the catalog", + ).toEqual([ + { template: "apiKey", kind: "apikey" }, + { template: "apikey-2", kind: "apikey" }, + ]); + expect( + integration.authMethods[1]?.placements, + "the second method carries its query placement", + ).toEqual([{ carrier: "query", name: "api_key", prefix: "" }]); + }).pipe( + Effect.ensuring( + client.integrations + .remove({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/scenarios/integrations.test.ts b/e2e/scenarios/integrations.test.ts new file mode 100644 index 000000000..ff86201d7 --- /dev/null +++ b/e2e/scenarios/integrations.test.ts @@ -0,0 +1,33 @@ +// Cross-target: a fresh workspace ships the built-in executor integration ready +// to use — it appears in the catalog, it cannot be removed, and it already +// contributes tools so an agent can start without any manual setup. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; + +const coreApi = composePluginApi([] as const); + +scenario( + "Integrations · a fresh workspace ships the built-in executor integration ready to use", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(coreApi, identity); + + const integrations = yield* client.integrations.list(); + const builtin = integrations.find((i) => i.slug === "executor"); + expect(builtin, "the 'executor' integration is in the catalog").toBeDefined(); + expect(builtin?.kind).toBe("built-in"); + expect(builtin?.canRemove, "the built-in integration is permanent").toBe(false); + + const tools = yield* client.tools.list(); + const executorTools = tools.filter((t) => t.integration === "executor"); + expect( + executorTools.length, + "tools are available out of the box, no connection setup needed", + ).toBeGreaterThan(0); + }), +); diff --git a/e2e/scenarios/mcp-execute.test.ts b/e2e/scenarios/mcp-execute.test.ts new file mode 100644 index 000000000..c7a21de90 --- /dev/null +++ b/e2e/scenarios/mcp-execute.test.ts @@ -0,0 +1,20 @@ +// Cross-target: the MCP surface — connect with fully headless OAuth (DCR → +// consent → code → token) and run code in the sandbox, exactly as an MCP +// client (Claude, Cursor, …) would. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; + +scenario("MCP · OAuth connect, then execute code in the sandbox", { needs: ["mcp-oauth"] }, (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const session = ctx.mcp.session(identity); + + const tools = yield* session.listTools(); + expect(tools, "the execute tool is advertised").toContain("execute"); + + const result = yield* session.call("execute", { code: "return 6 * 7;" }); + expect(result.text, "the sandbox returns the value").toBe("42"); + }), +); diff --git a/e2e/scenarios/openapi-source.test.ts b/e2e/scenarios/openapi-source.test.ts new file mode 100644 index 000000000..308e3d49a --- /dev/null +++ b/e2e/scenarios/openapi-source.test.ts @@ -0,0 +1,115 @@ +// Cross-target: registering an OpenAPI spec turns its operations into tools — +// the core "bring your own API" promise. Entirely through the typed client: +// the openapi plugin group (addSpec) composed onto the core API, then a +// connection via a `from` provider reference (no vault round-trip, so it works +// against the cloud stub), then the operation shows up in the tool catalog. +// +// Registration never calls the spec's server, so none is started here — +// actually invoking the tool against a live server is the follow-up scenario. +import { randomBytes, randomUUID } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** OpenAPI 3 spec with a single GET /greet operation. */ +const greetSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Greet API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/greet": { + get: { + operationId: "getGreeting", + summary: "Return a greeting message", + responses: { "200": { description: "A greeting" } }, + }, + }, + }, + }); + +scenario( + "OpenAPI · registering a spec exposes its operations as tools", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + + // Unique slug per run: selfhost shares the bootstrap-admin identity, so + // the prefix keeps parallel/repeated runs out of each other's catalogs. + const slug = `openapi-scn-greet-${randomBytes(4).toString("hex")}`; + const specBaseUrl = "http://127.0.0.1:59999"; // never contacted during registration + + yield* Effect.ensuring( + Effect.gen(function* () { + const added = yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: greetSpec(specBaseUrl) }, + slug, + baseUrl: specBaseUrl, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, + }, + ], + }, + }); + expect(added.toolCount, "the spec's operations were extracted as tools").toBeGreaterThan( + 0, + ); + expect(added.slug, "the integration keeps the requested slug").toBe(slug); + + // The catalog stamps tools once a connection exists; a `from` provider + // reference avoids any vault round-trip. + const providers = yield* client.providers.list(); + expect(providers.length, "a credential provider is available").toBeGreaterThan(0); + + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("apiKey"), + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, + }, + }); + + const tools = yield* client.tools.list(); + const mine = tools.filter((tool) => String(tool.integration) === slug).map((t) => t.name); + expect(mine.join(", "), "the spec's operation is in the tool catalog").toContain( + "getGreeting", + ); + }), + // Selfhost shares one bootstrap admin, so this scenario must not leak + // its connection or integration — otherwise a cross-target guarantee + // like "a fresh identity starts with zero connections" would see it. + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), +); diff --git a/e2e/scenarios/policies.test.ts b/e2e/scenarios/policies.test.ts new file mode 100644 index 000000000..b5edaf4ab --- /dev/null +++ b/e2e/scenarios/policies.test.ts @@ -0,0 +1,32 @@ +// Cross-target: policies CRUD through the typed HttpApiClient — a created +// policy comes back in the list with the shape that was sent. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; + +const coreApi = composePluginApi([] as const); + +scenario( + "Policies · a created policy appears in the list for the owning identity", + { needs: ["api"] }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(coreApi, identity); + + const created = yield* client.policies.create({ + payload: { owner: "org", pattern: "policies-scn.*", action: "block" }, + }); + expect(created.owner).toBe("org"); + expect(created.pattern).toBe("policies-scn.*"); + expect(created.action).toBe("block"); + + const list = yield* client.policies.list(); + const found = list.find((p) => p.id === created.id); + expect(found, "created policy appears in the list").toBeDefined(); + expect(found?.pattern, "listed entry preserves the pattern").toBe("policies-scn.*"); + expect(found?.action, "listed entry preserves the action").toBe("block"); + }), +); diff --git a/e2e/scenarios/tools-contract.test.ts b/e2e/scenarios/tools-contract.test.ts new file mode 100644 index 000000000..9d225861e --- /dev/null +++ b/e2e/scenarios/tools-contract.test.ts @@ -0,0 +1,25 @@ +// Cross-target: every advertised tool carries the minimal metadata an agent +// consumer needs to pick and invoke it — a non-empty address, name, and +// description. A failure names the offending tools. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; + +const coreApi = composePluginApi([] as const); + +scenario("Tools · every advertised tool is well-formed enough to call", { needs: ["api"] }, (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(coreApi, identity); + const tools = yield* client.tools.list(); + + expect(tools.length, "the catalog advertises tools").toBeGreaterThan(0); + + const malformed = tools + .filter((tool) => !(tool.address?.length && tool.name?.length && tool.description?.length)) + .map((tool) => tool.address || tool.name || "(unidentifiable tool)"); + expect(malformed, "tools missing an address, name, or description").toEqual([]); + }), +); diff --git a/e2e/scripts/rebuild-viewer.ts b/e2e/scripts/rebuild-viewer.ts new file mode 100644 index 000000000..7869bc602 --- /dev/null +++ b/e2e/scripts/rebuild-viewer.ts @@ -0,0 +1,20 @@ +// Rebuild the viewer over the existing run data without rerunning a single +// test: refresh runs/manifest.json + vite-build the SPA into runs/. +// Usage: bun e2e/scripts/rebuild-viewer.ts +import { execFileSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { buildManifest } from "../src/viewer/manifest"; + +const e2eDir = fileURLToPath(new URL("..", import.meta.url)); +const runsDir = join(e2eDir, "runs"); + +buildManifest(runsDir); +rmSync(join(runsDir, "assets"), { recursive: true, force: true }); +execFileSync("bunx", ["vite", "build", "--config", "viewer/vite.config.ts"], { + cwd: e2eDir, + stdio: "inherit", +}); +console.log(`viewer rebuilt at ${runsDir}`); diff --git a/e2e/scripts/serve.ts b/e2e/scripts/serve.ts new file mode 100644 index 000000000..46b78f37f --- /dev/null +++ b/e2e/scripts/serve.ts @@ -0,0 +1,81 @@ +// Static server for runs/ — the review URL. Supports range requests so the +// session videos seek/stream, gzips text assets, and marks vite's hashed +// /assets/ as immutable so Monaco/React chunks download once, ever. +// `bun e2e/scripts/serve.ts` → http://host:8901 +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createGzip } from "node:zlib"; + +const ROOT = fileURLToPath(new URL("../runs/", import.meta.url)); +const PORT = Number(process.env.PORT ?? 8901); + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".map": "application/json", + ".svg": "image/svg+xml", + ".json": "application/json", + ".ts": "text/plain; charset=utf-8", + ".png": "image/png", + ".webm": "video/webm", + ".mp4": "video/mp4", + ".zip": "application/zip", +}; + +const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]); + +createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://x"); + let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, ""); + if (path === "" || path === ".") path = "index.html"; + let file = join(ROOT, path); + // Directory request → its index.html (the page itself fixes a missing + // trailing slash client-side; a server redirect would drop the /runs mount). + if (file.startsWith(ROOT) && existsSync(file) && statSync(file).isDirectory()) { + file = join(file, "index.html"); + } + if (!file.startsWith(ROOT) || !existsSync(file) || !statSync(file).isFile()) { + res.writeHead(404).end("not found"); + return; + } + const size = statSync(file).size; + const ext = extname(file); + const type = MIME[ext] ?? "application/octet-stream"; + // trace.playwright.dev fetches trace.zip from the user's browser — allow it. + res.setHeader("access-control-allow-origin", "*"); + // Vite content-hashes /assets/ filenames → cache forever. Everything else + // (run data, index.html) must revalidate so fresh runs show up. + res.setHeader( + "cache-control", + path.startsWith("assets/") ? "public, max-age=31536000, immutable" : "no-cache", + ); + const range = /bytes=(\d+)-(\d*)/.exec(req.headers.range ?? ""); + if (range) { + const start = Number(range[1]); + const end = range[2] ? Number(range[2]) : size - 1; + res.writeHead(206, { + "content-type": type, + "content-range": `bytes ${start}-${end}/${size}`, + "accept-ranges": "bytes", + "content-length": end - start + 1, + }); + createReadStream(file, { start, end }).pipe(res); + return; + } + const wantsGzip = + COMPRESSIBLE.has(ext) && /\bgzip\b/.test(String(req.headers["accept-encoding"] ?? "")); + if (wantsGzip) { + res.writeHead(200, { + "content-type": type, + "content-encoding": "gzip", + vary: "accept-encoding", + }); + createReadStream(file).pipe(createGzip()).pipe(res); + return; + } + res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" }); + createReadStream(file).pipe(res); +}).listen(PORT, () => console.log(`e2e viewer → http://localhost:${PORT}/`)); diff --git a/e2e/selfhost/auth-methods-ui.test.ts b/e2e/selfhost/auth-methods-ui.test.ts new file mode 100644 index 000000000..1bcf84dd5 --- /dev/null +++ b/e2e/selfhost/auth-methods-ui.test.ts @@ -0,0 +1,156 @@ +// Selfhost-only (browser): the multi-method auth UX beyond the no-auth case — +// an OAuth-DETECTED server gets an API key declared alongside at add time, and +// the connect modal's "+ method" adds a custom API key to an OAuth integration +// without displacing it. Selfhost-only because cloud has no browser identity +// yet and these paste a credential into the default store; the cross-target +// no-auth add-flow variant lives in scenarios/auth-methods-ui.test.ts. The +// selfhost instance runs with EXECUTOR_ALLOW_LOCAL_NETWORK so its outbound +// probe/dial can reach the loopback test servers. Video is the artifact. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + makeGreetingMcpServer, + serveMcpServer, + serveMcpServerWithOAuth, +} from "@executor-js/plugin-mcp/testing"; +import { OAuthTestServer } from "@executor-js/sdk/testing"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "Auth methods · a detected-OAuth server gets an API key declared alongside", + { needs: ["browser"] }, + (ctx) => + Effect.scoped( + Effect.gen(function* () { + // An OAuth-PROTECTED server: the probe gets a 401 with protected- + // resource metadata pointing at the test OAuth issuer, so the method + // list seeds with the detected OAuth row. + const server = yield* serveMcpServerWithOAuth( + () => makeGreetingMcpServer({ name: `oauth-mcp-${randomBytes(3).toString("hex")}` }), + { path: "/mcp" }, + ); + const identity = yield* ctx.target.newIdentity(); + + yield* ctx.browser.session(identity, async ({ page, step }) => { + await step("Open the add-MCP flow pointed at the server", async () => { + await page.goto(`/integrations/add/mcp?url=${encodeURIComponent(server.endpoint)}`, { + waitUntil: "networkidle", + }); + await page.getByText("How does this server authenticate?").waitFor(); + }); + + await step("The probe detected OAuth", async () => { + await page.getByText("Method 1 · Detected").waitFor(); + // The OAuth editor declares discovery-at-connect, not pasted URLs. + await page.getByText("OAuth metadata is discovered from this server").waitFor(); + }); + + await step("Declare an API key method alongside OAuth", async () => { + await page.getByRole("button", { name: "Add method" }).click(); + await page.getByText("Method 2").waitFor(); + await page.getByPlaceholder("Authorization").last().waitFor(); + }); + + await step("Add the source with both methods", async () => { + await page.getByRole("button", { name: "Add source" }).click(); + await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); + await page.getByText("Connections").first().waitFor(); + }); + + await step("The connect modal offers OAuth and the API key", async () => { + await page.getByRole("button", { name: "Add connection" }).first().click(); + await page.getByRole("tab", { name: "OAuth" }).waitFor(); + await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); + }); + }); + }), + ).pipe(Effect.provide(OAuthTestServer.layer())), +); + +scenario( + "Auth methods · a custom method added in the connect modal keeps OAuth", + { needs: ["browser", "api"] }, + (ctx) => + Effect.scoped( + Effect.gen(function* () { + // A server that only accepts the bearer key — the connection created + // through the custom method must render it on the wire. + const token = `e2e-modal-key-${randomBytes(6).toString("hex")}`; + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === `Bearer ${token}`), + }, + }); + + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const slug = `mcp-modal-key-${randomBytes(3).toString("hex")}`; + + // The integration as the add flow would have left it: OAuth only. + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "OAuth-only MCP", + endpoint: server.endpoint, + slug, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + + // Remove the integration (and the connection it creates) afterward — + // selfhost identities share one tenant, so a leaked connection would + // break the "fresh identity has zero connections" scenario. + yield* Effect.gen(function* () { + yield* ctx.browser.session(identity, async ({ page, step }) => { + await step("Open the integration's connect modal", async () => { + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add connection" }).first().click(); + await page.getByRole("tab", { name: "OAuth" }).waitFor(); + }); + + await step("Add a custom API key method from the modal", async () => { + await page.getByRole("button", { name: "Add authentication method" }).click(); + await page.getByLabel("Method name").fill("Team API key"); + await page.getByPlaceholder("Authorization").fill("Authorization"); + await page.getByPlaceholder("Bearer ").fill("Bearer "); + await page.getByRole("button", { name: "Add method" }).click(); + }); + + await step("OAuth survives next to the new method", async () => { + await page.getByRole("tab", { name: "API key (Authorization)" }).waitFor(); + await page.getByRole("tab", { name: "OAuth" }).waitFor(); + }); + + await step("Connect through the new method", async () => { + await page.getByPlaceholder("paste the value / token").fill(token); + await page.getByRole("button", { name: "Add connection" }).click(); + await page.getByText("Connection added").waitFor(); + }); + }); + + // Wire proof: discovery for the new connection hit the server with + // the credential rendered through the custom method. + const requests = yield* server.requests; + expect( + requests.some((request) => request.authorization === `Bearer ${token}`), + "the server saw the bearer rendered through the custom method", + ).toBe(true); + }).pipe( + Effect.ensuring( + client.mcp + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), + ), +); diff --git a/e2e/selfhost/mcp-approve.test.ts b/e2e/selfhost/mcp-approve.test.ts new file mode 100644 index 000000000..35504872c --- /dev/null +++ b/e2e/selfhost/mcp-approve.test.ts @@ -0,0 +1,61 @@ +// Selfhost-only: an execution that triggers an approval gate pauses, then +// resumes successfully after `resume` is called with action "accept". +// +// Mechanism: create a `require_approval` policy scoped to the built-in tool +// `executor.coreTools.policies.list` via the typed HTTP API, then execute code +// over MCP that calls that tool. The engine hits the `enforceApproval` path +// and returns a paused result with an `executionId`; `session.approvePaused()` +// resumes it. The policy removal is an `ensuring` finalizer — a leaked +// require_approval gate on a built-in tool would pause unrelated scenarios on +// the shared selfhost instance. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; + +const coreApi = composePluginApi([] as const); + +const APPROVAL_TARGET_TOOL = "executor.coreTools.policies.list"; + +const EXECUTE_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +scenario("MCP · a paused execution resumes after human approval", { needs: ["mcp-oauth"] }, (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(coreApi, identity); + + const policy = yield* client.policies.create({ + payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" }, + }); + + yield* Effect.gen(function* () { + const session = ctx.mcp.session(identity); + + // Warm up the MCP session before the gated call so the OAuth handshake + // does not race with the policy window. + const tools = yield* session.listTools(); + expect(tools).toContain("execute"); + + const paused = yield* session.call("execute", { code: EXECUTE_CODE }); + expect(paused.text, "execution paused rather than completing").toContain("Execution paused"); + expect(paused.text, "paused result carries the executionId").toContain("executionId:"); + + const resumed = yield* session.approvePaused(paused.text); + expect(resumed.ok, "resumed execution completed without error").toBe(true); + expect(resumed.text, "the sandbox returned the gated tool's result").toContain( + APPROVAL_TARGET_TOOL, + ); + }).pipe( + // Always remove the gate, even when the test fails or times out. + Effect.ensuring( + client.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/selfhost/mcp-multi-auth.test.ts b/e2e/selfhost/mcp-multi-auth.test.ts new file mode 100644 index 000000000..5dfb893d3 --- /dev/null +++ b/e2e/selfhost/mcp-multi-auth.test.ts @@ -0,0 +1,91 @@ +// Selfhost-only: on a multi-method MCP integration, the connection's CHOSEN +// method is what renders the credential on the wire. A real MCP test server +// (in this test process — the selfhost dev server dials it over loopback) +// only accepts `Authorization: Bearer `. The integration declares BOTH +// OAuth and a bearer-header method; a connection created through the header +// method must discover the server's tools, and the recorded requests must +// carry the rendered header — proving template selection by slug, not by +// "whatever the config says". +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { makeGreetingMcpServer, serveMcpServer } from "@executor-js/plugin-mcp/testing"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "Auth methods · the connection's chosen method renders the credential on the wire", + { needs: ["api"] }, + (ctx) => + Effect.scoped( + Effect.gen(function* () { + const token = `e2e-key-${randomBytes(6).toString("hex")}`; + const server = yield* serveMcpServer(() => makeGreetingMcpServer(), { + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization === `Bearer ${token}`), + }, + }); + + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const slug = freshSlug("mcp-wire-auth"); + + // Two declared methods — the connection must pick the right one. + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Wire-auth MCP", + endpoint: server.endpoint, + slug, + authenticationTemplate: [ + { kind: "oauth2" }, + { kind: "header", headerName: "Authorization", prefix: "Bearer " }, + ], + }, + }); + + yield* Effect.gen(function* () { + // Create the connection through the HEADER method (template slug + // "header") with a pasted key. Tool discovery dials the server with + // the rendered credential at create time. + yield* client.connections.create({ + payload: { + owner: "org", + name: ConnectionName.make("wire-auth-key"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("header"), + value: token, + }, + }); + + const tools = yield* client.tools.list(); + const mine = tools.filter((tool) => String(tool.integration) === slug); + expect( + mine.map((tool) => String(tool.name)).join(", "), + "discovery through the header method found the server's tool", + ).toContain("simple_echo"); + + const requests = yield* server.requests; + expect( + requests.some((request) => request.authorization === `Bearer ${token}`), + "the server saw the credential rendered through the chosen method", + ).toBe(true); + }).pipe( + Effect.ensuring( + client.mcp + .removeServer({ params: { slug: IntegrationSlug.make(slug) } }) + .pipe(Effect.ignore), + ), + ); + }), + ), +); + +const freshSlug = (prefix: string): string => `${prefix}-${randomBytes(4).toString("hex")}`; diff --git a/e2e/selfhost/oauth-app-modal.test.ts b/e2e/selfhost/oauth-app-modal.test.ts new file mode 100644 index 000000000..5c1552a58 --- /dev/null +++ b/e2e/selfhost/oauth-app-modal.test.ts @@ -0,0 +1,169 @@ +// Selfhost (browser): a registered OAuth app is managed entirely from the +// Add-connection modal — there is no separate "OAuth apps" page. The user +// registers a bring-your-own app for an integration, edits its stored client +// id, and removes it, all from the OAuth app picker inside the modal. +// +// The integration only needs to DECLARE an OAuth method for the picker to show; +// registering/editing/removing an app touches stored credentials only and never +// calls the authorization/token endpoints, so a static issuer is enough and no +// OAuth provider is started. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; + +import { scenario } from "../src/scenario"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +/** Minimal OpenAPI 3 spec — one operation, server never contacted. */ +const greetSpec = (baseUrl: string): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "OAuth Modal API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/greet": { + get: { + operationId: "getGreeting", + summary: "Return a greeting", + responses: { "200": { description: "A greeting" } }, + }, + }, + }, + }); + +scenario( + "OAuth apps · a registered app is edited and removed from the connect modal", + { needs: ["browser"], timeout: 180_000 }, + (ctx) => + Effect.gen(function* () { + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + + // Selfhost shares the bootstrap-admin identity, so prefix every resource + // with a per-run id to stay out of parallel/repeated runs' way. + const id = randomBytes(4).toString("hex"); + const integration = `oauth-modal-scn-${id}`; + const appName = `oauthmodalapp${id}`; // lowercase+digits → slug === appName + // The picker humanizes the slug for display ("oauthmodalappab12" → + // "Oauthmodalappab12"); used to assert the row is gone after removal. + const appDisplayName = appName.charAt(0).toUpperCase() + appName.slice(1); + const specBaseUrl = "http://127.0.0.1:59998"; // never contacted + + // Stand up an integration that declares a bring-your-own OAuth2 method. + // The endpoints are inert — app management never calls them. + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: greetSpec(specBaseUrl) }, + slug: integration, + baseUrl: specBaseUrl, + authenticationTemplate: [ + { + slug: "oauth", + type: "oauth", + authorizationUrl: "https://auth.example/authorize", + tokenUrl: "https://auth.example/token", + scopes: [], + }, + ], + }, + }); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* ctx.browser.session(identity, async ({ page, step }) => { + const actions = page.getByRole("button", { name: `Actions for ${appName}` }); + + await step("Open the integration and start a new connection", async () => { + await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: "Add connection" }).click(); + // OAuth2 is the integration's only method, so the modal opens on + // the OAuth app step with nothing registered yet. (`exact` avoids + // the "Register app for help" tooltip button in the form.) + await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); + }); + + await step("Register a bring-your-own OAuth app", async () => { + await page.getByRole("button", { name: "Register app", exact: true }).click(); + await page.locator("#oauth-app-name").fill(appName); + await page.locator("#oauth-client-id").fill("client-one"); + await page.locator("#oauth-client-secret").fill("secret-one"); + await page.getByRole("button", { name: "Register app", exact: true }).click(); + // Back on the picker, the new app is selectable AND manageable — + // the per-app actions menu is what replaced the old apps page. + await actions.waitFor(); + }); + + await step("Edit opens the app prefilled with its stored client id", async () => { + await actions.click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + await page.getByText(`Edit ${appName}`).waitFor(); + expect( + await page.locator("#oauth-client-id").inputValue(), + "the edit form prefills the stored client id", + ).toBe("client-one"); + }); + + await step("Change the client id and save the edit", async () => { + await page.locator("#oauth-client-id").fill("client-two"); + await page.locator("#oauth-client-secret").fill("secret-two"); + await page.getByRole("button", { name: "Register app", exact: true }).click(); + await actions.waitFor(); + }); + + await step("Reopening the app shows the saved client id", async () => { + await actions.click(); + await page.getByRole("menuitem", { name: "Edit" }).click(); + await page.getByText(`Edit ${appName}`).waitFor(); + expect( + await page.locator("#oauth-client-id").inputValue(), + "the edit persisted to the saved app", + ).toBe("client-two"); + await page.getByRole("button", { name: "Cancel" }).click(); + await actions.waitFor(); + }); + + await step("Remove the app and confirm", async () => { + await actions.click(); + await page.getByRole("menuitem", { name: "Remove" }).click(); + await page.getByRole("button", { name: "Remove app" }).click(); + // The row (and its actions menu) is gone from the picker, and the + // empty-state register CTA is back — no app left for this method. + await actions.waitFor({ state: "detached" }); + await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); + // Scope to the modal so the "Removed …" success toast (which + // echoes the slug) doesn't count as a lingering picker row. + expect( + await page.getByRole("dialog").getByText(appDisplayName).count(), + "the removed app no longer appears in the picker", + ).toBe(0); + }); + }); + + // The removal is real, not just visual: the app is gone from the API + // (asserted before the finalizer, which would also remove it). + const remaining = yield* client.oauth.listClients(); + expect( + remaining.map((c) => String(c.slug)), + "the removed app is gone from the OAuth client catalog", + ).not.toContain(appName); + }), + // Finalizer: never leak the integration or a half-removed app into the + // shared selfhost instance, even if a step above failed mid-flow. + Effect.gen(function* () { + const clients = yield* client.oauth.listClients().pipe(Effect.orElseSucceed(() => [])); + for (const c of clients) { + if (String(c.slug) === appName) { + yield* client.oauth + .removeClient({ params: { slug: c.slug }, payload: { owner: c.owner } }) + .pipe(Effect.ignore); + } + } + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); + }), + ); + }), +); diff --git a/e2e/setup/boot.ts b/e2e/setup/boot.ts new file mode 100644 index 000000000..783f20632 --- /dev/null +++ b/e2e/setup/boot.ts @@ -0,0 +1,89 @@ +// Process glue for the per-target globalsetups: spawn the app's own dev +// server, wait until it answers HTTP, and hand vitest a teardown. The apps own +// what runs (their dev stack, their stub flags); this file only owns process +// lifecycle, so it stays target-agnostic. +import { spawn, type ChildProcess } from "node:child_process"; + +export interface BootedProcesses { + readonly teardown: () => Promise; +} + +export const bootProcesses = ( + procs: ReadonlyArray<{ + readonly cmd: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly env?: Record; + }>, + options: { readonly label: string }, +): BootedProcesses => { + const children: ChildProcess[] = []; + let tearingDown = false; + for (const proc of procs) { + const child = spawn(proc.cmd, [...proc.args], { + cwd: proc.cwd, + env: { ...process.env, ...proc.env }, + stdio: process.env.E2E_VERBOSE ? "inherit" : "ignore", + // Own process group, so teardown can signal the whole tree — `bunx vite` + // is a wrapper whose actual server child would otherwise outlive the + // kill and squat the port into the NEXT invocation's waitForHttp. + detached: true, + }); + child.on("exit", (code) => { + if (code !== 0 && code !== null && !tearingDown) { + console.error(`[e2e:${options.label}] ${proc.cmd} exited with ${code}`); + } + }); + children.push(child); + } + + // Signal the process GROUP (negative pid); fall back to the direct child + // when the group is already gone. + const signalTree = (child: ChildProcess, signal: NodeJS.Signals) => { + if (child.pid === undefined || child.exitCode !== null) return; + try { + process.kill(-child.pid, signal); + } catch { + child.kill(signal); + } + }; + + const exited = (child: ChildProcess): Promise => + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : new Promise((resolve) => child.once("exit", () => resolve())); + + return { + teardown: async () => { + tearingDown = true; + const allExited = Promise.all(children.map(exited)); + for (const child of children) signalTree(child, "SIGTERM"); + // Wait for a REAL exit (not a fixed sleep) — a lingering server would + // answer the next invocation's waitForHttp as a half-dead zombie. + const graceful = await Promise.race([ + allExited.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 5_000)), + ]); + if (!graceful) { + for (const child of children) signalTree(child, "SIGKILL"); + await Promise.race([allExited, new Promise((resolve) => setTimeout(resolve, 2_000))]); + } + }, + }; +}; + +export const waitForHttp = async (url: string, timeoutMs = 90_000): Promise => { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: "manual" }); + if (response.status < 500) return; + lastError = new Error(`status ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 400)); + } + throw new Error(`timed out waiting for ${url}: ${String(lastError)}`); +}; diff --git a/e2e/setup/cloud.globalsetup.ts b/e2e/setup/cloud.globalsetup.ts new file mode 100644 index 000000000..966ae50c6 --- /dev/null +++ b/e2e/setup/cloud.globalsetup.ts @@ -0,0 +1,71 @@ +// Boot the cloud target: the app's OWN dev stack (PGlite dev-db + vite dev) +// with EXECUTOR_E2E_STUB=1 — the one flag that makes `vite dev` a logged-in, +// fully-stubbed instance (multi-user WorkOS stub, free-plan Autumn, no +// network). Set E2E_CLOUD_URL to attach to an already-running instance +// instead (e.g. while iterating on a scenario). +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { bootProcesses, waitForHttp } from "./boot"; +import { CLOUD_BASE_URL, CLOUD_DB_PORT, CLOUD_PORT } from "../targets/cloud"; + +const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); + +export default async function setup(): Promise<(() => Promise) | void> { + if (process.env.E2E_CLOUD_URL) { + await waitForHttp(process.env.E2E_CLOUD_URL); + return; + } + + // Fresh stub DB per suite run — hermetic, like the selfhost data dir. The + // WorkOS stub mints org ids from a per-process counter, so a persisted DB + // from a previous invocation collides with the new boot's ids (identities + // land in polluted orgs / org creation 500s). + const dbPath = resolve(cloudDir, ".e2e-stub-db"); + rmSync(dbPath, { recursive: true, force: true }); + + const env = { + EXECUTOR_E2E_STUB: "1", + // The harness boots loopback MCP/OAuth test servers and points the + // instance at them; the hosted SSRF guard would otherwise block outbound + // probes/dials to localhost. Hermetic stub instance only. + ALLOW_LOCAL_NETWORK: "true", + // Stub creds — never contacted; the stub layers replace the clients. + WORKOS_API_KEY: "sk_e2e_stub", + WORKOS_CLIENT_ID: "client_e2e_stub", + WORKOS_COOKIE_PASSWORD: "e2e_cookie_password_0123456789abcdef0123456789abcdef", + AUTUMN_SECRET_KEY: "am_e2e_stub", + ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${CLOUD_DB_PORT}/postgres`, + EXECUTOR_DIRECT_DATABASE_URL: "true", + CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", + VITE_PUBLIC_SITE_URL: CLOUD_BASE_URL, + MCP_AUTHKIT_DOMAIN: "https://example.com", + MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL, + // Throwaway PGlite on its own port + dir so it never fights `bun dev`. + DEV_DB_PORT: String(CLOUD_DB_PORT), + DEV_DB_PATH: dbPath, + }; + + const procs = bootProcesses( + [ + { cmd: "bun", args: ["run", "scripts/dev-db.ts"], cwd: cloudDir, env }, + { + cmd: "bunx", + args: ["vite", "dev", "--port", String(CLOUD_PORT), "--strictPort", "--host", "127.0.0.1"], + cwd: cloudDir, + env, + }, + ], + { label: "cloud" }, + ); + + try { + await waitForHttp(CLOUD_BASE_URL); + } catch (error) { + await procs.teardown(); + throw error; + } + return procs.teardown; +} diff --git a/e2e/setup/selfhost.globalsetup.ts b/e2e/setup/selfhost.globalsetup.ts new file mode 100644 index 000000000..946c86203 --- /dev/null +++ b/e2e/setup/selfhost.globalsetup.ts @@ -0,0 +1,54 @@ +// Boot the selfhost target: the app's real dev server (`bunx --bun vite dev`, +// Bun required for bun:sqlite) on a throwaway data dir with known bootstrap +// admin credentials. Set E2E_SELFHOST_URL to attach to a running instance +// (with E2E_SELFHOST_ADMIN_EMAIL/PASSWORD matching it). +import { rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { bootProcesses, waitForHttp } from "./boot"; +import { SELFHOST_ADMIN, SELFHOST_BASE_URL, SELFHOST_PORT } from "../targets/selfhost"; + +const selfhostDir = fileURLToPath(new URL("../../apps/host-selfhost/", import.meta.url)); + +export default async function setup(): Promise<(() => Promise) | void> { + if (process.env.E2E_SELFHOST_URL) { + await waitForHttp(process.env.E2E_SELFHOST_URL); + return; + } + + // Fresh data dir per suite run — hermetic; in-suite isolation comes from + // fresh identities, not resets. + const dataDir = resolve(selfhostDir, ".e2e-data"); + rmSync(dataDir, { recursive: true, force: true }); + + const procs = bootProcesses( + [ + { + cmd: "bunx", + args: ["--bun", "vite", "dev", "--port", String(SELFHOST_PORT), "--strictPort"], + cwd: selfhostDir, + env: { + EXECUTOR_DATA_DIR: dataDir, + BETTER_AUTH_SECRET: "executor-selfhost-e2e-secret-0123456789", + EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email, + EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password, + EXECUTOR_WEB_BASE_URL: SELFHOST_BASE_URL, + // The harness boots loopback MCP/OAuth test servers and points the + // instance at them; the hosted SSRF guard would otherwise block + // outbound probes/dials to localhost. Hermetic test instance only. + EXECUTOR_ALLOW_LOCAL_NETWORK: "true", + }, + }, + ], + { label: "selfhost" }, + ); + + try { + await waitForHttp(SELFHOST_BASE_URL); + } catch (error) { + await procs.teardown(); + throw error; + } + return procs.teardown; +} diff --git a/e2e/src/scenario.ts b/e2e/src/scenario.ts new file mode 100644 index 000000000..a0d4e4fcf --- /dev/null +++ b/e2e/src/scenario.ts @@ -0,0 +1,169 @@ +// scenario(): the one way a test is written. Picks the target from E2E_TARGET +// (set by the vitest project), skips when the target lacks a needed capability, +// and provides the surface drivers. Correctness lives in the test code and its +// vitest assertions — there is no recording layer. What survives per run is a +// small result.json (for the scenario × target matrix) plus whatever artifacts +// the browser surface produced (video, screenshots, trace.zip). +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { it } from "@effect/vitest"; +import { Cause, Effect } from "effect"; +import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; + +import type { Capability, Target } from "./target"; +import { resolveTarget } from "../targets/registry"; +import { makeApiSurface, type ApiSurface } from "./surfaces/api"; +import { makeBrowserSurface, type BrowserSurface } from "./surfaces/browser"; +import { makeCliSurface, type CliSurface } from "./surfaces/cli"; +import { makeMcpSurface, type McpSurface } from "./surfaces/mcp"; +import { buildManifest } from "./viewer/manifest"; + +export const RUNS_DIR = fileURLToPath(new URL("../runs/", import.meta.url)); + +export const slugify = (text: string): string => + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + +export interface ScenarioContext { + readonly target: Target; + /** Artifact directory for this run (browser video/screenshots/trace land here). */ + readonly dir: string; + readonly api: ApiSurface; + readonly browser: BrowserSurface; + readonly cli: CliSurface; + readonly mcp: McpSurface; +} + +export interface ScenarioOptions { + readonly needs?: ReadonlyArray; + readonly timeout?: number; +} + +export const scenario = ( + name: string, + options: ScenarioOptions, + body: (ctx: ScenarioContext) => Effect.Effect, +): void => { + const target = resolveTarget(); + const missing = (options.needs ?? []).filter((c) => !target.capabilities.has(c)); + const dir = join(RUNS_DIR, target.name, slugify(name)); + const testFile = captureTestFile(); + + if (missing.length > 0) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "skipped.json"), + JSON.stringify({ scenario: name, target: target.name, missing }, null, 1), + ); + it.skip(`${name} [needs ${missing.join(", ")} — not on ${target.name}]`, () => {}); + return; + } + + it.live( + name, + () => + Effect.gen(function* () { + // A run's directory is the run — never mix artifacts across attempts. + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + const startedAt = Date.now(); + const ctx: ScenarioContext = { + target, + dir, + api: makeApiSurface(target), + browser: makeBrowserSurface(dir, target), + cli: makeCliSurface(), + mcp: makeMcpSurface(target), + }; + const exit = yield* Effect.exit(body(ctx)); + const endedAt = Date.now(); + const error = exit._tag === "Failure" ? failureMessage(exit.cause) : undefined; + // The test source is the review artifact — ship this scenario's code + // (imports + sibling scenarios stripped) alongside the run. + const source = testFile ? extractScenarioSource(testFile, name) : undefined; + if (source) writeFileSync(join(dir, "test.ts"), source); + writeFileSync( + join(dir, "result.json"), + JSON.stringify( + { + scenario: name, + target: target.name, + ok: exit._tag === "Success", + startedAt, + endedAt, + durationMs: endedAt - startedAt, + ...(error ? { error } : {}), + artifacts: readdirSync(dir).filter((f) => f !== "result.json"), + }, + null, + 1, + ), + ); + buildManifest(RUNS_DIR); + if (exit._tag === "Failure") { + return yield* Effect.failCause(exit.cause); + } + }).pipe(Effect.provide(FetchHttpClient.layer)), + options.timeout ?? 120_000, + ); +}; + +const failureMessage = (cause: Cause.Cause): string => { + const rendered = String(Cause.squash(cause)); + return rendered.length > 2_000 ? `${rendered.slice(0, 2_000)}…` : rendered; +}; + +/** The *.test.ts file that called scenario(), from the registration stack. */ +const captureTestFile = (): string | undefined => { + const stack = new Error().stack ?? ""; + for (const line of stack.split("\n")) { + const match = /\(?(?:file:\/\/)?(\/[^():]+\.test\.ts)/.exec(line); + if (match) return match[1]; + } + return undefined; +}; + +/** + * This scenario's code as a reader sees it: the file minus import statements + * and minus every OTHER scenario() block (module-level helpers stay — they're + * part of understanding the test). Falls back to undefined on any surprise so + * a parsing edge case can never fail a run. + */ +const extractScenarioSource = (filePath: string, name: string): string | undefined => { + try { + const source = readFileSync(filePath, "utf8").replace(/^import[\s\S]*?;[^\S\n]*$/gm, ""); + const needle = "scenario("; + const blocks: Array<{ start: number; end: number; mine: boolean }> = []; + let index = 0; + while ((index = source.indexOf(needle, index)) !== -1) { + let depth = 0; + let end = -1; + for (let i = index + needle.length - 1; i < source.length; i++) { + if (source[i] === "(") depth++; + else if (source[i] === ")") { + depth--; + if (depth === 0) { + end = source[i + 1] === ";" ? i + 2 : i + 1; + break; + } + } + } + if (end === -1) return undefined; // unbalanced — bail to be safe + blocks.push({ start: index, end, mine: source.slice(index, end).includes(`"${name}"`) }); + index = end; + } + if (!blocks.some((b) => b.mine)) return undefined; + let out = source; + for (const block of [...blocks].reverse()) { + if (!block.mine) out = out.slice(0, block.start) + out.slice(block.end); + } + return `${out.replace(/\n{3,}/g, "\n\n").trim()}\n`; + } catch { + return undefined; + } +}; diff --git a/e2e/src/surfaces/api.ts b/e2e/src/surfaces/api.ts new file mode 100644 index 000000000..1f0d87fc5 --- /dev/null +++ b/e2e/src/surfaces/api.ts @@ -0,0 +1,54 @@ +// API surface: the typed Effect `HttpApiClient` a real consumer codes against, +// over the wire to the target's dev server. Auth comes from the scenario's +// Identity — either ready-made headers (cloud's stub session cookie) or a +// Better Auth email sign-in (selfhost). Assertions and failure output are +// vitest's job; a failed call surfaces as a typed HttpClientError in the +// test output. +import { Effect } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import type { Identity, Target } from "../target"; + +type AnyApi = Parameters[0]; + +export interface ApiSurface { + /** Typed client for `apiDef`, authenticated as `identity`. */ + readonly client: ( + apiDef: A, + identity: Identity, + ) => Effect.Effect, unknown, HttpClient.HttpClient>; +} + +export const makeApiSurface = (target: Target): ApiSurface => ({ + client: (apiDef, identity) => + Effect.gen(function* () { + const headers = identity.headers ?? (yield* signInHeaders(target.baseUrl, identity)); + return yield* HttpApiClient.make(apiDef, { + baseUrl: new URL("/api", target.baseUrl).toString(), + transformClient: HttpClient.mapRequest((request) => + Object.entries(headers).reduce( + (req, [key, value]) => HttpClientRequest.setHeader(req, key, value), + request, + ), + ), + }); + }), +}); + +// Better Auth email sign-in → session cookie (selfhost). The `origin` header is +// required: Better Auth rejects state-changing requests without one. +const signInHeaders = (baseUrl: string, identity: Identity) => + Effect.promise(async (): Promise> => { + const credentials = identity.credentials; + if (!credentials) throw new Error(`identity ${identity.label} has no headers or credentials`); + const response = await fetch(new URL("/api/auth/sign-in/email", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, + body: JSON.stringify(credentials), + redirect: "manual", + }); + const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); + if (!cookie) throw new Error(`api: sign-in set no cookie (${response.status})`); + return { cookie }; + }); diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts new file mode 100644 index 000000000..c0d250283 --- /dev/null +++ b/e2e/src/surfaces/browser.ts @@ -0,0 +1,115 @@ +// Browser surface: Playwright over the target's real web UI, dark mode, with +// the standard debugging artifacts — a Playwright trace (time-travel DOM, +// network, console), the session video (transcoded to mp4 so it plays +// everywhere), per-step screenshots, and a failure screenshot. The scenario +// drives `page` directly; assertions are vitest's job. +import { execFile } from "node:child_process"; +import { copyFileSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { Effect } from "effect"; +import { chromium, type Page } from "playwright"; + +import type { Identity, Target } from "../target"; + +export interface BrowserSession { + readonly page: Page; + /** Perform one user-visible step; names the trace group + saves a screenshot. */ + readonly step: (label: string, action: (page: Page) => Promise) => Promise; +} + +export interface BrowserSurface { + readonly session: ( + identity: Identity, + drive: (session: BrowserSession) => Promise, + ) => Effect.Effect; +} + +const slug = (text: string): string => + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); + +// acquireUseRelease so a vitest timeout (fiber interruption) still closes the +// browser and flushes video + trace — a bare promise would leak Chromium. +export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface => ({ + session: (identity, drive) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const videoTmp = join(dir, ".video-tmp"); + mkdirSync(videoTmp, { recursive: true }); + + const browser = await chromium.launch(); + const context = await browser.newContext({ + colorScheme: "dark", + viewport: { width: 1280, height: 800 }, + recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, + baseURL: target.baseUrl, + }); + await context.tracing.start({ screenshots: true, snapshots: true, sources: true }); + if (identity.cookies?.length) { + await context.addCookies( + identity.cookies.map((cookie) => ({ ...cookie, url: target.baseUrl })), + ); + } + const page = await context.newPage(); + return { browser, context, page, videoTmp, shots: { count: 0 } }; + }), + ({ page, context, shots }) => + Effect.promise(async () => { + const step = async (label: string, action: (page: Page) => Promise) => { + await context.tracing.group(label); + try { + await action(page); + } finally { + await context.tracing.groupEnd(); + } + await page.screenshot({ + path: join(dir, `${String(shots.count++).padStart(2, "0")}-${slug(label)}.png`), + }); + }; + try { + await drive({ page, step }); + } catch (error) { + // Freeze the scene: the artifact dir shows the screen at failure. + await page.screenshot({ path: join(dir, "failure.png") }).catch(() => {}); + throw error; + } + }), + ({ browser, context, page, videoTmp }) => + Effect.promise(async () => { + await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {}); + const video = page.video(); + await context.close(); // flushes the recording + await browser.close(); + const recordedPath = await video?.path().catch(() => undefined); + if (recordedPath) { + try { + // mp4 plays everywhere (Safari/iOS don't do webm). + await promisify(execFile)("ffmpeg", [ + "-y", + "-i", + recordedPath, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "26", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + join(dir, "session.mp4"), + ]); + } catch { + copyFileSync(recordedPath, join(dir, "session.webm")); + } + } + rmSync(videoTmp, { recursive: true, force: true }); + }), + ), +}); diff --git a/e2e/src/surfaces/cli.ts b/e2e/src/surfaces/cli.ts new file mode 100644 index 000000000..050f6d6ee --- /dev/null +++ b/e2e/src/surfaces/cli.ts @@ -0,0 +1,41 @@ +// CLI/TUI surface: a real PTY via terminal-control. The scenario drives the +// session (type/press/waitForText) and asserts on the rendered screen with +// vitest; pass `record` to capture an asciinema-style cast file if wanted. +import { Effect } from "effect"; +import { TerminalControl, type Session } from "@kitlangton/terminal-control"; + +export interface CliSurface { + readonly session: ( + command: readonly [string, ...string[]], + drive: (session: Session) => Promise, + options?: { + readonly cwd?: string; + readonly env?: Record; + readonly record?: string; + }, + ) => Effect.Effect; +} + +// acquireUseRelease so a vitest timeout (fiber interruption) still tears the +// PTY down instead of leaking the child process. +export const makeCliSurface = (): CliSurface => ({ + session: (command, drive, options) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const tc = await TerminalControl.make(); + const session: Session = await tc.launch({ + command, + cwd: options?.cwd, + env: options?.env, + record: options?.record, + }); + return { tc, session }; + }), + ({ session }) => Effect.promise(() => drive(session)), + ({ tc, session }) => + Effect.promise(async () => { + await session.stop().catch(() => {}); + await tc[Symbol.asyncDispose](); + }), + ), +}); diff --git a/e2e/src/surfaces/mcp.ts b/e2e/src/surfaces/mcp.ts new file mode 100644 index 000000000..5c70c6c2e --- /dev/null +++ b/e2e/src/surfaces/mcp.ts @@ -0,0 +1,103 @@ +// MCP surface: the vendored mcporter fork as a programmatic MCP client, with +// headless OAuth via the target's consent strategy. Session methods are +// Effects; mcporter itself is promise-native underneath. Assertions are +// vitest's job. +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Effect } from "effect"; + +import { createRuntime, type Runtime } from "../../../vendor/mcporter/dist/index.js"; + +import type { Identity, Target } from "../target"; + +export interface McpCallResult { + readonly raw: unknown; + readonly text: string; + readonly ok: boolean; +} + +export interface McpSession { + readonly listTools: () => Effect.Effect>; + readonly call: (name: string, args?: Record) => Effect.Effect; + /** Find the paused executionId in `text` and resume it with approval. */ + readonly approvePaused: ( + text: string, + content?: Record, + ) => Effect.Effect; +} + +export interface McpSurface { + readonly session: (identity: Identity) => McpSession; +} + +const textOf = (result: unknown): string => { + const content = (result as { content?: Array<{ type: string; text?: string }> })?.content; + if (Array.isArray(content)) { + return content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + } + return typeof result === "string" ? result : JSON.stringify(result); +}; + +export const makeMcpSurface = (target: Target): McpSurface => ({ + session: (identity) => { + const serverName = target.name; + let runtimePromise: Promise | undefined; + let connected = false; + + const consent = target.mcpConsent?.(identity); + const callOptions = { + autoAuthorize: true, + oauthSessionOptions: consent ? { consentStrategy: consent } : {}, + }; + + const runtime = () => { + if (!runtimePromise) { + const dir = mkdtempSync(join(tmpdir(), "executor-e2e-mcp-")); + writeFileSync( + join(dir, "mcporter.json"), + JSON.stringify({ mcpServers: { [serverName]: { url: target.mcpUrl } } }), + ); + runtimePromise = createRuntime({ configPath: join(dir, "mcporter.json") }); + } + return runtimePromise; + }; + + const listTools = () => + Effect.promise(async () => { + const defs = await (await runtime()).listTools(serverName, callOptions); + connected = true; + return defs.map((tool: { name: string }) => tool.name); + }); + + const call = (name: string, args: Record = {}) => + Effect.promise(async (): Promise => { + if (!connected) { + await (await runtime()).listTools(serverName, callOptions); + connected = true; + } + const raw = await (await runtime()).callTool(serverName, name, { args, ...callOptions }); + const isError = Boolean((raw as { isError?: boolean })?.isError); + return { raw, text: textOf(raw), ok: !isError }; + }); + + return { + listTools, + call, + approvePaused: (text, content = {}) => + Effect.suspend(() => { + const match = /\bexecutionId:\s*(\S+)/.exec(text); + if (!match) return Effect.die(new Error("approvePaused: executionId not found in text")); + return call("resume", { + executionId: match[1], + action: "accept", + content: JSON.stringify(content), + }); + }), + }; + }, +}); diff --git a/e2e/src/target.ts b/e2e/src/target.ts new file mode 100644 index 000000000..32e1138ec --- /dev/null +++ b/e2e/src/target.ts @@ -0,0 +1,43 @@ +// A Target is one deployed shape of the product (cloud / selfhost / …) seen +// purely from the outside: base URLs, how to mint a fresh isolated identity, +// and which capabilities it supports. Scenarios are written once against this +// interface; vitest projects pick which target a run executes on via +// E2E_TARGET. Boot/teardown of the instance is NOT here — each app owns its +// own dev-server boot (see setup/*.globalsetup.ts which call into the app). +import type { Effect } from "effect"; + +export type Capability = + | "api" // typed HttpApiClient over the wire + | "browser" // web UI reachable + identity injectable into a browser context + | "mcp-oauth" // MCP endpoint with a headless OAuth consent path + | "cli" // a CLI/TUI entry point exists for this target + | "billing"; // billing limits are enforced (cloud-only) + +export interface Identity { + /** Shown in transcripts ("user_ab12cd") */ + readonly label: string; + /** Headers that authenticate API requests (e.g. a session cookie). */ + readonly headers?: Record; + /** Cookies to inject into a browser context for a logged-in page. */ + readonly cookies?: ReadonlyArray<{ readonly name: string; readonly value: string }>; + /** Credentials for surfaces that sign in themselves (Better Auth, OAuth consent). */ + readonly credentials?: { readonly email: string; readonly password: string }; +} + +export interface Target { + readonly name: string; + readonly baseUrl: string; + readonly mcpUrl: string; + readonly capabilities: ReadonlySet; + /** + * Mint a fresh identity — THE isolation model: no resets, every scenario + * is its own user (and org where applicable) on the shared instance. + * `org: false` yields an identity with no active organization (for flows + * that create one, like onboarding / billing limits). + */ + readonly newIdentity: (options?: { readonly org?: boolean }) => Effect.Effect; + /** Headless OAuth consent for the MCP surface, when "mcp-oauth" is supported. */ + readonly mcpConsent?: ( + identity: Identity, + ) => (request: { authorizationUrl: string }) => Promise<{ code: string }>; +} diff --git a/e2e/src/viewer/manifest.ts b/e2e/src/viewer/manifest.ts new file mode 100644 index 000000000..641f8ade5 --- /dev/null +++ b/e2e/src/viewer/manifest.ts @@ -0,0 +1,78 @@ +// Writes runs/manifest.json — the machine-readable inventory the matrix +// renders (scenario × target + per-run status). Rebuilt after every scenario. +import { + existsSync, + readFileSync, + readdirSync, + renameSync, + writeFileSync, + type Dirent, +} from "node:fs"; +import { join } from "node:path"; + +export interface ManifestRun { + readonly scenario: string; + readonly target: string; + readonly slug: string; + readonly ok: boolean; + readonly durationMs?: number; + readonly endedAt?: number; +} + +export interface ManifestSkip { + readonly scenario: string; + readonly target: string; + readonly missing: ReadonlyArray; +} + +export const buildManifest = (runsDir: string): void => { + const runs: ManifestRun[] = []; + const skips: ManifestSkip[] = []; + + for (const target of readdirSync(runsDir, { withFileTypes: true })) { + if (!target.isDirectory() || target.name === "assets") continue; + // Both vitest projects build the manifest concurrently while runs are + // being (re)written — tolerate dirs vanishing mid-scan. + let slugs: Dirent[]; + try { + slugs = readdirSync(join(runsDir, target.name), { withFileTypes: true }); + } catch { + continue; + } + for (const slug of slugs) { + if (!slug.isDirectory()) continue; + const dir = join(runsDir, target.name, slug.name); + const resultPath = join(dir, "result.json"); + if (existsSync(resultPath)) { + try { + const result = JSON.parse(readFileSync(resultPath, "utf8")); + runs.push({ + scenario: result.scenario, + target: target.name, + slug: slug.name, + ok: result.ok, + durationMs: result.durationMs, + endedAt: result.endedAt, + }); + continue; + } catch { + // unreadable result — fall through to the skip marker + } + } + const skipPath = join(dir, "skipped.json"); + if (existsSync(skipPath)) { + try { + const skip = JSON.parse(readFileSync(skipPath, "utf8")); + skips.push({ scenario: skip.scenario, target: target.name, missing: skip.missing }); + } catch { + // ignore + } + } + } + } + + // Write-then-rename so a concurrent reader/writer never sees a torn file. + const tmp = join(runsDir, `.manifest-${process.pid}.tmp`); + writeFileSync(tmp, JSON.stringify({ generatedAt: Date.now(), runs, skips }, null, 1)); + renameSync(tmp, join(runsDir, "manifest.json")); +}; diff --git a/e2e/targets/cloud.ts b/e2e/targets/cloud.ts new file mode 100644 index 000000000..90fcc1c08 --- /dev/null +++ b/e2e/targets/cloud.ts @@ -0,0 +1,57 @@ +// The cloud app as a target: the stubbed dev server (`vite dev` + +// EXECUTOR_E2E_STUB=1) — real SSR, real routes, real PGlite-backed DB, WorkOS +// and Autumn stubbed in-memory. Isolation: the stub resolves the user FROM the +// wos-session cookie value, so every identity is a fresh user (and org) on the +// one shared instance — no resets. Boot lives in setup/cloud.globalsetup.ts. +import { randomUUID } from "node:crypto"; + +import { Effect } from "effect"; + +import type { Identity, Target } from "../src/target"; + +export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798); +export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436); +export const CLOUD_BASE_URL = process.env.E2E_CLOUD_URL ?? `http://127.0.0.1:${CLOUD_PORT}`; + +const freshId = () => randomUUID().slice(0, 8); + +export const cloudTarget = (): Target => ({ + name: "cloud", + baseUrl: CLOUD_BASE_URL, + mcpUrl: `${CLOUD_BASE_URL}/mcp`, + capabilities: new Set(["api", "browser", "billing"]), + newIdentity: ({ org = true } = {}) => + Effect.promise(async (): Promise => { + const user = `user_${freshId()}`; + // The stub WorkOS resolves the user FROM the cookie value; a bare value + // is a fresh signed-in user with no organization yet. + let value = user; + if (org) { + // Go through the REAL product flow: create the org via the session + // route and adopt the refreshed `|org:` cookie it sets — + // membership and session state come from the same path real users take. + const response = await fetch(new URL("/api/auth/create-organization", CLOUD_BASE_URL), { + method: "POST", + headers: { + "content-type": "application/json", + origin: new URL(CLOUD_BASE_URL).origin, + cookie: `wos-session=${value}`, + }, + body: JSON.stringify({ name: `Org ${user}` }), + }); + if (!response.ok) { + throw new Error(`cloud newIdentity: create-organization → ${response.status}`); + } + const refreshed = (response.headers.getSetCookie?.() ?? []) + .map((cookie) => /^wos-session=([^;]+)/.exec(cookie)?.[1]) + .find(Boolean); + if (!refreshed) throw new Error("cloud newIdentity: no refreshed session cookie"); + value = decodeURIComponent(refreshed); + } + return { + label: user, + headers: { cookie: `wos-session=${value}` }, + cookies: [{ name: "wos-session", value }], + }; + }), +}); diff --git a/e2e/targets/registry.ts b/e2e/targets/registry.ts new file mode 100644 index 000000000..d1bfb0df4 --- /dev/null +++ b/e2e/targets/registry.ts @@ -0,0 +1,27 @@ +// Target resolution: the vitest project sets E2E_TARGET; scenarios resolve it +// once per worker. Adding a target = one factory entry here + a project in +// vitest.config.ts + a globalsetup that boots (or attaches to) the instance. +import type { Target } from "../src/target"; +import { cloudTarget } from "./cloud"; +import { selfhostTarget } from "./selfhost"; + +const factories: Record Target> = { + cloud: cloudTarget, + selfhost: selfhostTarget, +}; + +let current: Target | undefined; + +export const resolveTarget = (): Target => { + if (current) return current; + const name = process.env.E2E_TARGET; + const factory = name ? factories[name] : undefined; + if (!factory) { + throw new Error( + `E2E_TARGET=${JSON.stringify(name)} — expected one of: ${Object.keys(factories).join(", ")}. ` + + `Run via the vitest projects (e.g. \`vitest run --project cloud\`).`, + ); + } + current = factory(); + return current; +}; diff --git a/e2e/targets/selfhost.ts b/e2e/targets/selfhost.ts new file mode 100644 index 000000000..97e2ebc57 --- /dev/null +++ b/e2e/targets/selfhost.ts @@ -0,0 +1,73 @@ +// The self-host app as a target: its real dev server (`bunx --bun vite dev`) +// on a throwaway data dir, with Better Auth + the bootstrap admin. MCP OAuth +// is fully headless via the mcporter fork's cookieConsentStrategy. Boot lives +// in setup/selfhost.globalsetup.ts. +import { Effect } from "effect"; + +import { cookieConsentStrategy } from "../../vendor/mcporter/dist/index.js"; + +import type { Identity, Target } from "../src/target"; + +export const SELFHOST_PORT = Number(process.env.E2E_SELFHOST_PORT ?? 4799); +export const SELFHOST_BASE_URL = + process.env.E2E_SELFHOST_URL ?? `http://localhost:${SELFHOST_PORT}`; + +export const SELFHOST_ADMIN = { + email: process.env.E2E_SELFHOST_ADMIN_EMAIL ?? "admin@e2e.test", + password: process.env.E2E_SELFHOST_ADMIN_PASSWORD ?? "e2e-admin-password-123", +}; + +// Sign the bootstrap admin in via Better Auth email and return the session +// cookie in both shapes we need: the `Cookie` header the API surface attaches, +// and the {name,value} list Playwright injects into a browser context. The +// `origin` header is required — Better Auth rejects state-changing requests +// without it. +const signInSession = async ( + baseUrl: string, + credentials: { readonly email: string; readonly password: string }, +): Promise<{ + readonly cookieHeader: string; + readonly cookies: ReadonlyArray<{ readonly name: string; readonly value: string }>; +}> => { + const response = await fetch(new URL("/api/auth/sign-in/email", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, + body: JSON.stringify(credentials), + redirect: "manual", + }); + const pairs = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]!.trim()); + if (pairs.length === 0) throw new Error(`selfhost: sign-in set no cookie (${response.status})`); + const cookies = pairs.map((pair) => { + const eq = pair.indexOf("="); + return { name: pair.slice(0, eq), value: pair.slice(eq + 1) }; + }); + return { cookieHeader: pairs.join("; "), cookies }; +}; + +export const selfhostTarget = (): Target => ({ + name: "selfhost", + baseUrl: SELFHOST_BASE_URL, + mcpUrl: `${SELFHOST_BASE_URL}/mcp`, + // No "billing" (no limits). Identity is the bootstrap admin for now — + // single-tenant; per-test invite-signup isolation is the next step here, so + // browser scenarios must prefix the resources they create. + capabilities: new Set(["api", "browser", "mcp-oauth"]), + newIdentity: () => + Effect.promise(async (): Promise => { + // Sign in once and carry the session in both shapes: `headers` for the + // API surface, `cookies` for an injectable logged-in browser context. + const { cookieHeader, cookies } = await signInSession(SELFHOST_BASE_URL, SELFHOST_ADMIN); + return { + label: SELFHOST_ADMIN.email, + credentials: SELFHOST_ADMIN, + headers: { cookie: cookieHeader }, + cookies, + }; + }), + mcpConsent: (identity: Identity) => + cookieConsentStrategy({ + appBaseUrl: SELFHOST_BASE_URL, + email: identity.credentials?.email ?? SELFHOST_ADMIN.email, + password: identity.credentials?.password ?? SELFHOST_ADMIN.password, + }), +}); diff --git a/e2e/viewer/index.html b/e2e/viewer/index.html new file mode 100644 index 000000000..b26489e0e --- /dev/null +++ b/e2e/viewer/index.html @@ -0,0 +1,19 @@ + + + + + + Executor e2e runs + + + +
+ + + diff --git a/e2e/viewer/src/App.tsx b/e2e/viewer/src/App.tsx new file mode 100644 index 000000000..8eed5e8d5 --- /dev/null +++ b/e2e/viewer/src/App.tsx @@ -0,0 +1,250 @@ +import React, { Suspense, useEffect, useState } from "react"; + +const TestSource = React.lazy(() => import("./TestSource")); + +// --------------------------------------------------------------------------- +// The matrix (scenario × target health) plus a per-run artifact page. The +// test SOURCE is where correctness is reviewed; this site only answers "is +// everything green" and hands you the debugging artifacts (Playwright trace, +// session video, screenshots, failure output) for any run. +// --------------------------------------------------------------------------- + +interface ManifestRun { + scenario: string; + target: string; + slug: string; + ok: boolean; + durationMs?: number; + endedAt?: number; +} + +interface Manifest { + generatedAt: number; + runs: ManifestRun[]; + skips: Array<{ scenario: string; target: string; missing: string[] }>; +} + +interface RunResult { + scenario: string; + target: string; + ok: boolean; + startedAt: number; + endedAt: number; + durationMs: number; + error?: string; + artifacts: string[]; +} + +const useRoute = () => { + const [hash, setHash] = useState(window.location.hash); + useEffect(() => { + const onChange = () => setHash(window.location.hash); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }, []); + const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean); + return parts.length >= 2 ? { target: parts[0], slug: parts[1] } : null; +}; + +export const App = () => { + const route = useRoute(); + return route ? : ; +}; + +// --------------------------------------------------------------------------- +// Matrix +// --------------------------------------------------------------------------- + +const Matrix = () => { + const [manifest, setManifest] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + fetch("manifest.json") + .then((r) => r.json()) + .then(setManifest) + .catch((e) => setError(String(e))); + }, []); + + if (error) return
failed to load manifest.json: {error}
; + if (!manifest) return
loading…
; + + const targets = [...new Set(manifest.runs.map((r) => r.target))].sort(); + const scenarios = [ + ...new Set([...manifest.runs, ...manifest.skips].map((r) => r.scenario)), + ].sort(); + const runFor = (scenario: string, target: string) => + manifest.runs + .filter((r) => r.scenario === scenario && r.target === target) + .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0]; + const skipFor = (scenario: string, target: string) => + manifest.skips.find((s) => s.scenario === scenario && s.target === target); + + return ( +
+

Executor e2e — every scenario, on every deployment

+

+ Click a result for that run's artifacts (Playwright trace, video, screenshots, failure + output). “—” = capability not on that target. +

+ + + + + {targets.map((t) => ( + + ))} + + + + {scenarios.map((scenario) => ( + + + {targets.map((target) => { + const run = runFor(scenario, target); + if (run) { + return ( + + ); + } + return ( + + ); + })} + + ))} + +
scenario{t}
{scenario} + + {run.ok ? "✓ passed" : "✗ FAILED"} + {run.durationMs != null && ( + {(run.durationMs / 1000).toFixed(1)}s + )} + + + {skipFor(scenario, target) ? "—" : "·"} +
+

generated {new Date(manifest.generatedAt).toLocaleString()}

+
+ ); +}; + +// --------------------------------------------------------------------------- +// Run page: status + error + artifacts. The trace opens in Playwright's own +// viewer (trace.playwright.dev fetches the zip from this server, client-side). +// --------------------------------------------------------------------------- + +const RunView = ({ target, slug }: { target: string; slug: string }) => { + const base = `${target}/${slug}`; + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState<"video" | "source">("video"); + + useEffect(() => { + fetch(`${base}/result.json`) + .then((r) => r.json()) + .then(setResult) + .catch((e) => setError(String(e))); + }, [base]); + + if (error) return
failed to load run: {error}
; + if (!result) return
loading…
; + + const has = (name: string) => result.artifacts.includes(name); + const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort(); + const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null; + const traceUrl = has("trace.zip") + ? `https://trace.playwright.dev/?trace=${encodeURIComponent( + new URL(`${base}/trace.zip`, window.location.href).toString(), + )}` + : null; + + return ( +
+ +

+ {result.ok ? "✓ PASSED" : "✗ FAILED"} · {result.scenario} +

+

+ {result.target} · {(result.durationMs / 1000).toFixed(1)}s ·{" "} + {new Date(result.endedAt).toLocaleString()} +

+ {result.error &&
{result.error}
} + {video && has("test.ts") && ( +
+ + +
+ )} + {(!video || tab === "source") && has("test.ts") && ( + loading test source…

}> + {!video &&

The test

} + +
+ )} + {video && tab === "video" && ( + <> + {/* muted is required for browsers to honor autoplay */} +
+ ); +}; + +const labelOf = (file: string): string => + file + .replace(/\.png$/, "") + .replace(/^\d+-/, "") + .replace(/-/g, " "); diff --git a/e2e/viewer/src/TestSource.tsx b/e2e/viewer/src/TestSource.tsx new file mode 100644 index 000000000..3de0f22c1 --- /dev/null +++ b/e2e/viewer/src/TestSource.tsx @@ -0,0 +1,62 @@ +// Read-only Monaco showing the run's test source (the scenario's code with +// imports + sibling tests stripped, written by the runner as test.ts). +// Uses Monaco CORE + the monarch TypeScript colorizer only — no language +// service, no ts.worker — a read-only pane needs highlighting, not IntelliSense +// (the full build is ~12 MB of workers). Lazy-loaded so the matrix stays light. +import React, { useEffect, useRef, useState } from "react"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import "monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution"; +import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; + +declare global { + interface Window { + MonacoEnvironment?: { getWorker: (workerId: string, label: string) => Worker }; + } +} +self.MonacoEnvironment = { getWorker: () => new EditorWorker() }; + +export default function TestSource({ url }: { url: string }) { + const container = useRef(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let editor: monaco.editor.IStandaloneCodeEditor | undefined; + let cancelled = false; + fetch(url) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.text(); + }) + .then((text) => { + if (cancelled || !container.current) return; + const lines = text.split("\n").length; + container.current.style.height = `${Math.min(Math.max(lines * 19 + 20, 140), 680)}px`; + editor = monaco.editor.create(container.current, { + value: text, + language: "typescript", + theme: "vs-dark", + readOnly: true, + domReadOnly: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12.5, + lineNumbers: "on", + renderLineHighlight: "none", + contextmenu: false, + folding: false, + automaticLayout: true, + scrollbar: { alwaysConsumeMouseWheel: false }, + stickyScroll: { enabled: false }, + overviewRulerLanes: 0, + }); + }) + .catch(() => setFailed(true)); + return () => { + cancelled = true; + editor?.dispose(); + }; + }, [url]); + + if (failed) return null; + return
; +} diff --git a/e2e/viewer/src/main.tsx b/e2e/viewer/src/main.tsx new file mode 100644 index 000000000..1717091a3 --- /dev/null +++ b/e2e/viewer/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/e2e/viewer/src/styles.css b/e2e/viewer/src/styles.css new file mode 100644 index 000000000..fa9c221c0 --- /dev/null +++ b/e2e/viewer/src/styles.css @@ -0,0 +1,181 @@ +/* Dark GitHub-ish palette. */ +body { + font: + 15px/1.55 ui-sans-serif, + system-ui; + margin: 0; + color: #d7dce5; + background: #0b0f17; +} +.page { + max-width: 920px; + margin: 0 auto; + padding: 24px; +} +a { + color: #58a6ff; + text-decoration: none; +} +h1 { + font-size: 17px; + margin: 0 0 4px; +} +.hint { + color: #6b7785; + font-size: 13px; + margin-top: 2px; +} +.dim { + color: #3d4651; +} +.stamp { + color: #3d4651; + font-size: 12px; + margin-top: 1.2rem; +} +.ok-text { + color: #7ee787; +} +.error-text { + color: #ff7b72; +} + +/* matrix */ +table { + border-collapse: collapse; + width: 100%; + margin-top: 1rem; +} +th, +td { + padding: 0.5rem 0.7rem; + border-bottom: 1px solid #161b22; + text-align: left; + font-size: 14px; +} +th { + color: #6b7785; + font-weight: 600; +} +a.watch { + display: inline-block; + padding: 0.2rem 0.55rem; + border: 1px solid #21262d; + border-radius: 7px; + background: #0f1620; + white-space: nowrap; + font-weight: 600; +} +a.watch:hover { + border-color: #388bfd; + background: #101a2a; +} +a.watch.ok { + color: #7ee787; +} +a.watch.no { + color: #ff7b72; +} +.d { + color: #6b7785; + font-weight: 400; + font-size: 12px; +} + +/* run page */ +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} +.tool-link { + font-size: 13px; + border: 1px solid #21262d; + border-radius: 7px; + background: #0f1620; + padding: 0.25rem 0.7rem; + margin-left: 8px; +} +.tool-link:hover { + border-color: #388bfd; +} +.errbox { + background: #160b0b; + border: 1px solid #6e2a2a; + color: #ff7b72; + border-radius: 8px; + padding: 0.7rem 0.9rem; + font-size: 12.5px; + overflow: auto; + max-height: 320px; + white-space: pre-wrap; +} +.hero-video { + display: block; + width: 100%; + margin-top: 0.8rem; + border: 1px solid #21262d; + border-radius: 10px; + background: #010409; +} +.shots { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + margin-top: 1rem; +} +.shots figure { + margin: 0; +} +.shots img { + display: block; + width: 100%; + border: 1px solid #21262d; + border-radius: 8px; +} +.shots figcaption { + color: #8b98a9; + font-size: 12.5px; + margin-top: 4px; +} + +/* test source (monaco) */ +.section { + font-size: 13px; + color: #6b7785; + font-weight: 600; + margin: 1.2rem 0 0.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.code { + border: 1px solid #21262d; + border-radius: 8px; + overflow: hidden; +} + +/* run-page tabs (video / source) */ +.tabs { + display: flex; + gap: 6px; + margin: 1rem 0 0.6rem; + border-bottom: 1px solid #161b22; +} +.tab { + font: inherit; + font-size: 13px; + color: #6b7785; + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 0.35rem 0.7rem; + cursor: pointer; +} +.tab:hover { + color: #d7dce5; +} +.tab.active { + color: #d7dce5; + border-bottom-color: #388bfd; +} diff --git a/e2e/viewer/vite.config.ts b/e2e/viewer/vite.config.ts new file mode 100644 index 000000000..b0565b098 --- /dev/null +++ b/e2e/viewer/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// The viewer SPA builds INTO e2e/runs/ next to the run data it renders, with +// relative asset paths + hash routing — so the one static directory serves +// from any mount point (locally at /, via tailscale at /runs/). +export default defineConfig({ + root: import.meta.dirname, + base: "./", + plugins: [react()], + build: { + outDir: "../runs", + emptyOutDir: false, + }, +}); diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts new file mode 100644 index 000000000..463352420 --- /dev/null +++ b/e2e/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "vitest/config"; + +// One project per target. Same scenario files, different running instance: +// `vitest run --project cloud` / `--project selfhost` (or both, the default). +// Each project's globalsetup boots that app's OWN dev server (or attaches to +// E2E__URL). Scenarios are isolated by fresh identities, not resets. +const project = (name: string, overrides: Record = {}) => ({ + test: { + name, + include: ["scenarios/**/*.test.ts", `${name}/**/*.test.ts`], + env: { E2E_TARGET: name }, + globalSetup: [`./setup/${name}.globalsetup.ts`], + testTimeout: 180_000, + hookTimeout: 120_000, + ...overrides, + }, +}); + +export default defineConfig({ + test: { + projects: [ + project("cloud"), + // selfhost identities are the shared bootstrap admin for now — run files + // serially until per-test invite-signup isolation lands. + project("selfhost", { fileParallelism: false }), + ], + }, +}); diff --git a/examples/all-plugins/CHANGELOG.md b/examples/all-plugins/CHANGELOG.md index 6792a00c2..eb43919e3 100644 --- a/examples/all-plugins/CHANGELOG.md +++ b/examples/all-plugins/CHANGELOG.md @@ -1,4 +1,43 @@ -# @executor-js/example-all-plugins changelog +# @executor-js/example-all-plugins -This file exists for Changesets release workflow compatibility. -Canonical user-facing release notes are published on GitHub Releases. +## 0.0.22 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/plugin-file-secrets@1.5.2 + - @executor-js/plugin-graphql@1.5.2 + - @executor-js/plugin-keychain@1.5.2 + - @executor-js/plugin-mcp@1.5.2 + - @executor-js/plugin-onepassword@1.5.2 + - @executor-js/plugin-openapi@1.5.2 + - @executor-js/plugin-workos-vault@0.0.2 + +## 0.0.21 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + - @executor-js/plugin-file-secrets@1.5.1 + - @executor-js/plugin-graphql@1.5.1 + - @executor-js/plugin-keychain@1.5.1 + - @executor-js/plugin-mcp@1.5.1 + - @executor-js/plugin-onepassword@1.5.1 + - @executor-js/plugin-openapi@1.5.1 + - @executor-js/plugin-workos-vault@0.0.2 + +## 0.0.20 + +### Patch Changes + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad), [`9c9bcb6`](https://github.com/RhysSullivan/executor/commit/9c9bcb663e48ebb21a71f8058812319c1ec2a242)]: + - @executor-js/sdk@1.5.0 + - @executor-js/plugin-openapi@1.5.0 + - @executor-js/plugin-file-secrets@1.5.0 + - @executor-js/plugin-graphql@1.5.0 + - @executor-js/plugin-keychain@1.5.0 + - @executor-js/plugin-mcp@1.5.0 + - @executor-js/plugin-onepassword@1.5.0 + - @executor-js/plugin-workos-vault@0.0.2 diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index f15d75ff7..b1856703c 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-all-plugins", - "version": "0.0.19", + "version": "0.0.22", "private": true, "type": "module", "scripts": { diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index 747976d72..02106a699 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -1,67 +1,90 @@ // --------------------------------------------------------------------------- // examples/all-plugins // -// Wires every ported plugin into a single Executor and walks through the -// common flows: secrets, static control tools, dynamic source registration, -// tool invocation, filtered listing, and shutdown. +// Wires every ported plugin into a single Executor and walks the common v2 +// flows: credential providers, integration registration, connection creation +// (a connection IS the credential), per-connection tool production, execution, +// filtered listing, and shutdown. // -// This is what an app/local or app/cloud bootstrap file looks like under -// the new SDK shape — minus the HTTP API layer, runtime lifecycle, and -// scope persistence that real apps add on top. +// This is what an app/local or app/cloud bootstrap file looks like under the +// v2 SDK shape — minus the HTTP API layer, runtime lifecycle, and owner/tenant +// persistence that real apps add on top. // // Runs against the SDK's ephemeral in-memory FumaDB backend so you can -// `bun run src/main.ts` and watch the whole surface exercise itself. -// Plugins that need external infra (keychain prompts, 1Password unlock, -// MCP transport, WorkOS Vault, Google OAuth) are wired so their secret -// providers and extensions exist, but the flows that hit their -// backends are gated behind env vars and skipped by default. +// `bun run src/main.ts` and watch the whole surface exercise itself. Plugins +// that need external infra (keychain prompts, 1Password unlock, MCP transport, +// WorkOS Vault, Google OAuth) are wired so their credential providers and +// extensions exist, but the flows that hit their backends are skipped by +// default. // --------------------------------------------------------------------------- -import { Cause, Effect } from "effect"; +import { Cause, Effect, Result } from "effect"; -import { SecretId, Scope, ScopeId, SetSecretInput, createExecutor } from "@executor-js/sdk"; +import { + AuthTemplateSlug, + ConnectionName, + createExecutor, + IntegrationSlug, + ProviderItemId, + ProviderKey, + Tenant, + ToolAddress, + type CredentialProvider, +} from "@executor-js/sdk"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; import { graphqlPlugin } from "@executor-js/plugin-graphql"; import { keychainPlugin } from "@executor-js/plugin-keychain"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; -import { openApiPlugin } from "@executor-js/plugin-openapi"; +import { openApiPlugin, variable } from "@executor-js/plugin-openapi"; import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; // --------------------------------------------------------------------------- // 1. Build the ExecutorConfig. // -// Three pieces only: scope, FumaDB, plugins. -// Compare to the old SDK, where you'd pass pre-built ToolRegistry, -// SourceRegistry, SecretStore, and PolicyEngine service instances. +// Three pieces: tenant, plugins, and credential providers. The executor +// auto-registers every `plugin.credentialProviders`; `config.providers` adds +// inline ones (registered first, so they win as the default writable store). +// Compare to v1, where you'd pass pre-built ToolRegistry, SourceRegistry, +// SecretStore, and PolicyEngine service instances plus a scope stack. // --------------------------------------------------------------------------- -const scope = Scope.make({ - id: ScopeId.make("example-scope"), - name: "/tmp/example-workspace", - createdAt: new Date(), -}); +// A connection's value lives in a writable credential provider. This tiny +// in-memory store is enough for a script; the keychain / file-secrets / +// 1Password plugins below contribute durable ones. Providers are Effect-native, +// so `get`/`set` return `Effect`s. +const memory = new Map(); +const memoryProvider: CredentialProvider = { + key: ProviderKey.make("memory"), + writable: true, + get: (id: ProviderItemId) => Effect.sync(() => memory.get(String(id)) ?? null), + set: (id: ProviderItemId, value: string) => + Effect.sync(() => { + memory.set(String(id), value); + }), +}; const plugins = [ - // Secret providers — three of them contributed by three plugins. - // The executor auto-registers each one at startup via the new - // `plugin.secretProviders` field. + // Credential providers — three of them contributed by three plugins. The + // executor auto-registers each one at startup via `plugin.credentialProviders` + // (the v2 successor to v1's `secretProviders`). A connection routes its value + // through one of these. keychainPlugin(), fileSecretsPlugin(), onepasswordPlugin(), - // Source plugins — these declare their own schemas (tables) and - // register tools dynamically when the user adds a spec / connects - // to a server / imports a discovery document. + // Integration plugins — these declare their own schemas (tables), register + // integrations via their extension methods (`addSpec` / `addServer` / + // `addIntegration`), and produce tools per connection. graphqlPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: false }), openApiPlugin(), - // workos-vault is a cloud-hosted secret provider. It would contribute - // a "workos-vault" provider if credentials were available. We skip it - // here because it needs a real WorkOS API key; uncomment and supply - // credentials to wire it in. + // workos-vault is a cloud-hosted credential provider. It would contribute a + // "workos-vault" provider if credentials were available. We skip it here + // because it needs a real WorkOS API key; uncomment and supply credentials to + // wire it in. // // workosVaultPlugin({ // credentials: { @@ -76,8 +99,8 @@ const plugins = [ void workosVaultPlugin; // --------------------------------------------------------------------------- -// 2. A tiny OpenAPI spec we'll use to demonstrate dynamic source -// registration. Five operations, all deterministic. +// 2. A tiny OpenAPI spec we'll use to demonstrate integration + connection +// registration. Four operations, all deterministic. // --------------------------------------------------------------------------- const exampleOpenApiSpec = JSON.stringify({ @@ -157,9 +180,13 @@ const program = Effect.gen(function* () { console.log("=".repeat(72)); const executor = yield* createExecutor({ - scopes: [scope], + tenant: Tenant.make("example-tenant"), plugins, + providers: [memoryProvider], onElicitation: "accept-all" as const, + // `redirectUri` is intentionally omitted: this example never runs an + // interactive OAuth flow. A host that serves OAuth must pass + // `${webBaseUrl}/oauth/callback` here (there is no localhost default). }); // Every plugin's extension is accessible as `executor[pluginId]`. @@ -174,85 +201,73 @@ const program = Effect.gen(function* () { console.log(" executor.openapi ", typeof executor.openapi); // ------------------------------------------------------------------------- - // Secrets — three providers were contributed by plugins. List them, then - // store a secret pinned to the `file` provider (file-secrets writes to a - // local auth.json under $XDG_DATA_HOME). + // Credential providers — the inline `memory` store plus whichever plugin + // providers were reachable (keychain/file/1Password register at startup). + // A connection routes its value through one of these; there is no separate + // secret store in v2 (a connection IS the saved credential). // ------------------------------------------------------------------------- console.log("\n" + "-".repeat(72)); - console.log("Secrets"); + console.log("Credential providers"); console.log("-".repeat(72)); - const providers = yield* executor.secrets.providers(); - console.log("Registered providers:", providers); - - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("example-api-token"), - scope: "example-scope" as SetSecretInput["scope"], - name: "Example API Token", - value: "sk-example-redacted", - provider: "file", - }), - ); - - const token = yield* executor.secrets.get("example-api-token"); - console.log("Stored + read 'example-api-token':", token); - - const secretRefs = yield* executor.secrets.list(); + const providerKeys = yield* executor.providers.list(); console.log( - "Secret refs:", - secretRefs.map((r) => `${r.id}@${r.provider}`), + "Registered providers:", + providerKeys.map((k) => String(k)), ); // ------------------------------------------------------------------------- - // Static control tools — every source plugin exposes its built-in - // control tools via `staticSources`. They live in memory, not in the - // DB, and show up in `tools.list()` alongside dynamic ones. + // Integration: OpenAPI — register a tiny spec as an integration. The + // `authenticationTemplate` declares WHERE a connection's value renders on + // each request (here an `X-API-Key` header); `variable("token")` is the slot + // the resolved credential fills. // ------------------------------------------------------------------------- console.log("\n" + "-".repeat(72)); - console.log("Static sources / control tools"); - console.log("-".repeat(72)); - - const sourcesBefore = yield* executor.sources.list(); - const staticSources = sourcesBefore.filter((s) => s.runtime); - console.log( - `Runtime sources (${staticSources.length}):`, - staticSources.map((s) => s.id), - ); - - const toolsBefore = yield* executor.tools.list(); - const staticTools = toolsBefore.filter((t) => t.sourceId.endsWith(".control")); - console.log( - `Static control tools (${staticTools.length}):`, - staticTools.map((t) => t.id), - ); - - // ------------------------------------------------------------------------- - // Dynamic source: OpenAPI — register a tiny spec. Four tools land in - // the `tool` table under a `example-api` source, plus one `$defs` entry - // (the `Item` schema) lands in the `definition` table for $ref - // resolution at read time. - // ------------------------------------------------------------------------- - - console.log("\n" + "-".repeat(72)); - console.log("Dynamic source: OpenAPI"); + console.log("Integration: OpenAPI"); console.log("-".repeat(72)); const addSpecResult = yield* executor.openapi.addSpec({ spec: { kind: "blob", value: exampleOpenApiSpec }, - namespace: "example-api", - name: "Example API", + slug: "example-api", + description: "Example API", baseUrl: "https://example.com/api", - scope: "example-scope", + authenticationTemplate: [ + { + slug: AuthTemplateSlug.make("apiKey"), + type: "apiKey", + headers: { "X-API-Key": [variable("token")] }, + }, + ], + }); + console.log("Registered OpenAPI integration:", { + slug: String(addSpecResult.slug), + toolCount: addSpecResult.toolCount, }); - console.log("Registered OpenAPI source:", addSpecResult); - const exampleTools = yield* executor.tools.list({ sourceId: "example-api" }); + // A connection is the credential. Creating one with an inline `value` writes + // it to the default writable provider (`memory`) and produces the + // integration's per-connection tools, addressed + // `tools.example-api.org.default.`. + const openApiConnection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("default"), + integration: IntegrationSlug.make("example-api"), + template: AuthTemplateSlug.make("apiKey"), + value: "sk-example-redacted", + }); + console.log("Created connection:", { + address: String(openApiConnection.address), + provider: String(openApiConnection.provider), + }); + + const exampleTools = yield* executor.tools.list({ + integration: IntegrationSlug.make("example-api"), + }); console.log( "Tools under 'example-api':", - exampleTools.map((t) => t.name), + exampleTools.map((t) => String(t.address)), ); // Annotations are derived at read time via plugin.resolveAnnotations. @@ -260,28 +275,32 @@ const program = Effect.gen(function* () { console.log( "Annotations on example-api tools:", exampleTools.map((t) => ({ - name: t.name, + name: String(t.name), requiresApproval: t.annotations?.requiresApproval ?? false, })), ); - // `tools.schema` walks the read path: reads the tool row, attaches - // matching $defs from the core `definition` table. - const getItemSchema = yield* executor.tools.schema("example-api.items.get"); - console.log( - "Schema for items.get has $defs?", - getItemSchema?.inputSchema && - typeof getItemSchema.inputSchema === "object" && - "$defs" in getItemSchema.inputSchema, - ); + // `tools.schema` walks the read path: reads the tool row, attaches matching + // $defs (the `Item` schema) for $ref resolution. + const getItemTool = exampleTools.find((t) => String(t.name).startsWith("items__get")); + if (getItemTool) { + const getItemSchema = yield* executor.tools.schema(getItemTool.address); + console.log( + "Schema for items.get has $defs?", + getItemSchema?.inputSchema && + typeof getItemSchema.inputSchema === "object" && + "$defs" in getItemSchema.inputSchema, + ); + } // ------------------------------------------------------------------------- - // Dynamic source: GraphQL — introspect via a canned JSON doc so we - // don't need a real server running. + // Integration: GraphQL — introspect via a canned JSON doc so we don't need a + // real server running, then connect (this endpoint needs no credential, so + // the connection carries an empty value through a "none" template). // ------------------------------------------------------------------------- console.log("\n" + "-".repeat(72)); - console.log("Dynamic source: GraphQL"); + console.log("Integration: GraphQL"); console.log("-".repeat(72)); const introspectionJson = JSON.stringify({ @@ -351,28 +370,41 @@ const program = Effect.gen(function* () { }, }); - const gqlResult = yield* executor.graphql.addSource({ + const gqlResult = yield* executor.graphql.addIntegration({ endpoint: "https://example.com/graphql", name: "Example GraphQL", introspectionJson, - namespace: "example-graphql", - scope: "example-scope", + slug: "example-graphql", + }); + console.log("Registered GraphQL integration:", gqlResult); + + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("default"), + integration: IntegrationSlug.make("example-graphql"), + template: AuthTemplateSlug.make("none"), + value: "", }); - console.log("Registered GraphQL source:", gqlResult); - const graphqlTools = yield* executor.tools.list({ sourceId: "example-graphql" }); + const graphqlTools = yield* executor.tools.list({ + integration: IntegrationSlug.make("example-graphql"), + }); console.log( "Tools under 'example-graphql':", graphqlTools.map((t) => ({ - name: t.name, + address: String(t.address), requiresApproval: t.annotations?.requiresApproval ?? false, })), ); // ------------------------------------------------------------------------- - // MCP, Google OAuth, 1Password — shown but not exercised (they need - // real external infrastructure). Their extension methods exist, and - // calling them would register real dynamic sources the same way. + // Other plugin extensions — shown but not exercised (they need real external + // infrastructure). Their extension methods exist and would register real + // integrations + connections the same way. + // + // removed: v1 `executor.secrets.set/get/list` and credential bindings — a + // connection now IS the credential (see the OpenAPI flow above), and its value + // lives in a registered provider rather than a free-floating secret store. // ------------------------------------------------------------------------- console.log("\n" + "-".repeat(72)); @@ -381,13 +413,35 @@ const program = Effect.gen(function* () { console.log(" executor.keychain.isSupported:", executor.keychain.isSupported); console.log(" executor.keychain.displayName:", executor.keychain.displayName); - console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath); - // executor.mcp.addSource({ connector: { kind: "remote", endpoint: "..." } }); - // executor.openapi.addSpec({ spec: { kind: "googleDiscovery", url: "..." }, ... }); + // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." }); + // executor.openapi.addSpec({ spec: { kind: "googleDiscovery", url: "..." }, slug: "..." }); // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." }); + // ------------------------------------------------------------------------- + // Execute a tool over its connection. The executor resolves the connection's + // credential (from the `memory` provider) and hands it to the owning plugin's + // invokeTool, which renders it through the auth template onto the request. + // (The example.com host isn't real, so this surfaces a transport-level + // failure — the point is the resolve + dispatch path, not a live response.) + // ------------------------------------------------------------------------- + + console.log("\n" + "-".repeat(72)); + console.log("Execute over a connection"); + console.log("-".repeat(72)); + + const listItemsTool = exampleTools.find((t) => String(t.name).startsWith("items__list")); + if (listItemsTool) { + const outcome = yield* Effect.result( + executor.execute(ToolAddress.make(String(listItemsTool.address)), {}), + ); + console.log( + `execute ${String(listItemsTool.address)}:`, + Result.isSuccess(outcome) ? "ok" : `failed (${outcome.failure.constructor.name})`, + ); + } + // ------------------------------------------------------------------------- // Whole-catalog tools listing + filtering // ------------------------------------------------------------------------- @@ -399,20 +453,27 @@ const program = Effect.gen(function* () { const allTools = yield* executor.tools.list(); console.log(`Total tools: ${allTools.length}`); - const allSources = yield* executor.sources.list(); + const allIntegrations = yield* executor.integrations.list(); + console.log( + `Total integrations: ${allIntegrations.length}`, + allIntegrations.map((i) => String(i.slug)), + ); + + const allConnections = yield* executor.connections.list(); console.log( - `Total sources: ${allSources.length} (${allSources.filter((s) => s.runtime).length} runtime, ${allSources.filter((s) => !s.runtime).length} dynamic)`, + `Total connections: ${allConnections.length}`, + allConnections.map((c) => String(c.address)), ); const mutationTools = yield* executor.tools.list({ query: "create" }); console.log( "Tools matching 'create':", - mutationTools.map((t) => t.id), + mutationTools.map((t) => String(t.address)), ); // ------------------------------------------------------------------------- - // Shutdown — close() is called on every plugin that declared a `close` - // hook (the cache-backed ones like MCP tear down their connection pool). + // Shutdown — close() is called on every plugin that declared a `close` hook + // (the cache-backed ones like MCP tear down their connection pool). // ------------------------------------------------------------------------- console.log("\n" + "-".repeat(72)); diff --git a/examples/docs-sdk-quickstart/CHANGELOG.md b/examples/docs-sdk-quickstart/CHANGELOG.md index 8b69ab6b6..4d4952e78 100644 --- a/examples/docs-sdk-quickstart/CHANGELOG.md +++ b/examples/docs-sdk-quickstart/CHANGELOG.md @@ -1,6 +1,25 @@ -# @executor-js/example-docs-sdk-quickstart changelog +# @executor-js/example-docs-sdk-quickstart -This file exists for `changesets/action@v1` compatibility (it reads every -workspace package's `CHANGELOG.md` to build the Version Packages PR). -Canonical user-facing release notes are at `apps/cli/release-notes/next.md` -and on the GitHub Releases page. +## 0.0.7 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/plugin-openapi@1.5.2 + +## 0.0.6 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + - @executor-js/plugin-openapi@1.5.1 + +## 0.0.5 + +### Patch Changes + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]: + - @executor-js/sdk@1.5.0 + - @executor-js/plugin-openapi@1.5.0 diff --git a/examples/docs-sdk-quickstart/package.json b/examples/docs-sdk-quickstart/package.json index da53580c4..202affd09 100644 --- a/examples/docs-sdk-quickstart/package.json +++ b/examples/docs-sdk-quickstart/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.4", + "version": "0.0.7", "private": true, "type": "module", "scripts": { @@ -10,7 +10,8 @@ }, "dependencies": { "@executor-js/plugin-openapi": "workspace:*", - "@executor-js/sdk": "workspace:*" + "@executor-js/sdk": "workspace:*", + "effect": "catalog:" }, "devDependencies": { "@types/node": "catalog:", diff --git a/examples/docs-sdk-quickstart/src/main.ts b/examples/docs-sdk-quickstart/src/main.ts index 7415c94f8..b81907eec 100644 --- a/examples/docs-sdk-quickstart/src/main.ts +++ b/examples/docs-sdk-quickstart/src/main.ts @@ -1,7 +1,13 @@ // This example is the source of truth for docs snippets on /sdk/quickstart. // Run `bun run docs:snippets` after editing docs:start/docs:end blocks. -import { createExecutor } from "@executor-js/sdk/promise"; -import { openApiPlugin } from "@executor-js/plugin-openapi/promise"; +import { Effect } from "effect"; +import { + createExecutor, + ProviderItemId, + ProviderKey, + type CredentialProvider, +} from "@executor-js/sdk/promise"; +import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; const inventoryApi = { openapi: "3.0.0", @@ -70,36 +76,74 @@ const inventoryApi = { }; // docs:start create-executor +// A connection stores its value in a writable credential provider. This tiny +// in-memory store is enough for a script; production hosts swap in a durable +// provider (keychain, 1Password, an encrypted DB store, …). Providers are +// Effect-native, so `get`/`set` return `Effect`s. +const memory = new Map(); +const memoryProvider: CredentialProvider = { + key: ProviderKey.make("memory"), + writable: true, + get: (id: ProviderItemId) => Effect.sync(() => memory.get(String(id)) ?? null), + set: (id: ProviderItemId, value: string) => + Effect.sync(() => { + memory.set(String(id), value); + }), +}; + const executor = await createExecutor({ - scopes: [{ id: "docs-workspace", name: "Docs Workspace" }], plugins: [openApiPlugin()], + providers: [memoryProvider], onElicitation: "accept-all", }); // docs:end create-executor -// docs:start add-source +// docs:start add-integration +// An integration is the API surface. The apiKey template declares where a +// connection's credential is placed on each request — here, an `X-API-Key` +// header. `variable("token")` is the slot the resolved credential renders into. await executor.openapi.addSpec({ - namespace: "inventory", - scope: "docs-workspace", - name: "Inventory API", + slug: "inventory", + description: "Inventory API", baseUrl: "https://inventory.example.com", spec: { kind: "blob", value: JSON.stringify(inventoryApi), }, + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { "X-API-Key": [variable("token")] }, + }, + ], +}); +// docs:end add-integration + +// docs:start create-connection +// Tools are produced per connection. A connection is the saved credential for +// one integration; creating one with an inline `value` writes it to the default +// writable provider and yields the integration's tools. +await executor.connections.create({ + owner: "org", + name: "default", + integration: "inventory", + template: "apiKey", + value: "inventory-api-key", }); -// docs:end add-source +// docs:end create-connection // docs:start list-tools -const tools = await executor.tools.list({ sourceId: "inventory" }); +const tools = await executor.tools.list({ integration: "inventory" }); for (const tool of tools) { - console.log(`${tool.id}: ${tool.description}`); + console.log(`${tool.address}: ${tool.description}`); } // docs:end list-tools // docs:start inspect-schema -const schema = await executor.tools.schema("inventory.listItems"); +const firstAddress = tools[0]?.address; +const schema = firstAddress ? await executor.tools.schema(firstAddress) : null; console.log(schema?.inputTypeScript ?? "No input required"); // docs:end inspect-schema diff --git a/examples/promise-sdk/src/main.ts b/examples/promise-sdk/src/main.ts index d14463ed2..f61ff40a5 100644 --- a/examples/promise-sdk/src/main.ts +++ b/examples/promise-sdk/src/main.ts @@ -2,45 +2,92 @@ * Example: Promise-based executor SDK with MCP, OpenAPI, and GraphQL * — no Effect knowledge or database setup needed. Uses the SDK's * ephemeral in-memory FumaDB backend by default. + * + * v2 model: an *integration* is the API surface (added via the plugin's + * extension), and a *connection* is the credential for that integration. Tools + * are produced per connection and addressed as + * `tools....`. A connection's value lives + * in a writable CredentialProvider — here a tiny in-memory store registered via + * `providers`. There is no separate secret store: a connection *is* the secret. */ -import { createExecutor } from "@executor-js/sdk/promise"; +import { + createExecutor, + ProviderItemId, + ProviderKey, + type CredentialProvider, +} from "@executor-js/sdk/promise"; +import { Effect } from "effect"; import { mcpPlugin } from "@executor-js/plugin-mcp/promise"; -import { openApiPlugin } from "@executor-js/plugin-openapi/promise"; +import { openApiPlugin, variable } from "@executor-js/plugin-openapi/promise"; import { graphqlPlugin } from "@executor-js/plugin-graphql/promise"; // --------------------------------------------------------------------------- // 1. Create the executor with all plugins +// +// A connection stores its value in a writable credential provider. This tiny +// in-memory store is enough for a script; production hosts swap in a durable +// provider (keychain, 1Password, an encrypted DB store). Providers are +// Effect-native, so `get`/`set` return `Effect`s. // --------------------------------------------------------------------------- const plugins = [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const; +const memory = new Map(); +const memoryProvider: CredentialProvider = { + key: ProviderKey.make("memory"), + writable: true, + get: (id: ProviderItemId) => Effect.sync(() => memory.get(String(id)) ?? null), + set: (id: ProviderItemId, value: string) => + Effect.sync(() => { + memory.set(String(id), value); + }), +}; + const executor = await createExecutor({ - scopes: [{ id: "my-app", name: "my-app" }], plugins, + providers: [memoryProvider], onElicitation: "accept-all", }); // --------------------------------------------------------------------------- -// 2. MCP — connect to remote or local servers +// 2. MCP — register a remote server as an integration, then connect to it. +// +// `addServer` registers the catalog entry; `connections.create` produces the +// per-connection tools. Context7 needs no credential, so the connection's value +// is an empty string applied through the "none" template. // --------------------------------------------------------------------------- -await executor.mcp.addSource({ +const context7 = await executor.mcp.addServer({ transport: "remote", name: "Context7", endpoint: "https://mcp.context7.com/mcp", - scope: "my-app", + slug: "context7", +}); + +await executor.connections.create({ + owner: "org", + name: "default", + integration: context7.slug, + template: "none", + value: "", }); -// Stdio server -// await executor.mcp.addSource({ +// Stdio server (disabled by default — pass `dangerouslyAllowStdioMCP: true` to +// mcpPlugin() to enable, only for trusted local contexts): +// await executor.mcp.addServer({ // transport: "stdio", // name: "My Server", // command: "npx", // args: ["-y", "@my/mcp-server"], +// slug: "my-server", // }); // --------------------------------------------------------------------------- -// 3. OpenAPI — load specs by URL +// 3. OpenAPI — load a spec by URL as an integration, then connect. +// +// Petstore is public, so the connection carries a throwaway value. To require a +// real key, declare an `authenticationTemplate` (see Stripe below) and create a +// connection whose `value` is the token. // --------------------------------------------------------------------------- await executor.openapi.addSpec({ @@ -48,73 +95,98 @@ await executor.openapi.addSpec({ kind: "url", url: "https://petstore3.swagger.io/api/v3/openapi.json", }, - namespace: "petstore", - scope: "my-app", - name: "Petstore", + slug: "petstore", + description: "Petstore", baseUrl: "https://petstore3.swagger.io/api/v3", }); -// With auth headers (static or secret-backed) -// await executor.secrets.set( -// new SetSecretInput({ id: "stripe-key", name: "Stripe Key", value: "sk_live_..." }), -// ); +await executor.connections.create({ + owner: "org", + name: "default", + integration: "petstore", + template: "none", + value: "", +}); + +// Auth-backed integration: declare where the connection's value renders (here an +// `Authorization: Bearer ` header), then create a connection with the +// real token. The value is written to the `memory` provider and applied to the +// template lazily, per request — never pre-baked into the spec. // await executor.openapi.addSpec({ -// spec: "https://raw.githubusercontent.com/.../stripe.json", -// namespace: "stripe", -// headers: { -// Authorization: { secretId: "stripe-key", prefix: "Bearer " }, -// }, +// spec: { kind: "url", url: "https://raw.githubusercontent.com/.../stripe.json" }, +// slug: "stripe", +// authenticationTemplate: [ +// { +// slug: "bearer", +// type: "apiKey", +// headers: { Authorization: ["Bearer ", variable("token")] }, +// }, +// ], +// }); +// await executor.connections.create({ +// owner: "org", +// name: "default", +// integration: "stripe", +// template: "bearer", +// value: "sk_live_...", // }); +void variable; // --------------------------------------------------------------------------- -// 4. GraphQL — introspect endpoints +// 4. GraphQL — introspect an endpoint as an integration, then connect. // --------------------------------------------------------------------------- -await executor.graphql.addSource({ +await executor.graphql.addIntegration({ endpoint: "https://graphql.anilist.co", name: "AniList", - namespace: "anilist", - scope: "my-app", + slug: "anilist", +}); + +await executor.connections.create({ + owner: "org", + name: "default", + integration: "anilist", + template: "none", + value: "", }); // --------------------------------------------------------------------------- -// 5. Unified tool catalog — all plugins, one list +// 5. Unified tool catalog — all plugins, one list, addressed per connection. // --------------------------------------------------------------------------- const tools = await executor.tools.list(); console.log(`\n${tools.length} tools across all plugins:`); for (const t of tools) { - console.log(` [${t.pluginId}] ${t.id} — ${t.description}`); + console.log(` [${t.pluginId}] ${t.address} — ${t.description}`); } -const firstPetstoreTool = tools.find((t) => t.sourceId === "petstore"); +const firstPetstoreTool = tools.find((t) => t.integration === "petstore"); if (firstPetstoreTool) { - const schema = await executor.tools.schema(firstPetstoreTool.id); + const schema = await executor.tools.schema(firstPetstoreTool.address); console.log(`\n${firstPetstoreTool.name} input: ${schema?.inputTypeScript ?? ""}`); } // --------------------------------------------------------------------------- -// 6. Invoke tools — same interface regardless of plugin +// 6. Execute tools — same interface regardless of plugin. The executor resolves +// the connection's credential and hands it to the owning plugin. // --------------------------------------------------------------------------- -const anilistTool = tools.find((t) => t.sourceId === "anilist"); +const anilistTool = tools.find((t) => t.integration === "anilist"); if (anilistTool) { - const result = await executor.tools.invoke(anilistTool.id, {}); + const result = await executor.execute(anilistTool.address, {}); console.log("\nResult:", result); } // --------------------------------------------------------------------------- -// 7. Secrets — shared across all plugins +// 7. Connections are the credentials — list and inspect them across all plugins. +// (v2 has no separate `executor.secrets`; a connection IS the saved credential, +// and its value lives in a registered provider.) // --------------------------------------------------------------------------- -await executor.secrets.set({ - id: "api-key", - scope: "my-app", - name: "Shared API Key", - value: "sk_...", -}); - -const resolved = await executor.secrets.get("api-key"); -console.log("Secret:", resolved); +const connections = await executor.connections.list(); +console.log(`\n${connections.length} connections:`); +for (const c of connections) { + console.log(` ${c.address} (provider: ${c.provider})`); +} await executor.close(); diff --git a/package.json b/package.json index 3056682ad..58906fcd3 100644 --- a/package.json +++ b/package.json @@ -25,21 +25,22 @@ "packages/react", "packages/app", "apps/*", - "examples/*" + "examples/*", + "e2e" ], "type": "module", "scripts": { "dev": "turbo run dev --filter='!@executor-js/desktop' --filter='!@executor-js/cloud'", "dev:desktop": "turbo run dev", "dev:cli": "EXECUTOR_DEV=1 EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-apps/local/.executor-dev} bun run apps/cli/src/main.ts", - "test": "turbo run test", + "test": "turbo run test --filter=!@executor-js/e2e", + "test:e2e": "bun run --cwd e2e test", "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts", "build:packages": "bun run --filter='fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build", "typecheck": "turbo run typecheck", "typecheck:slow": "turbo run typecheck:slow", "ci": "bun run lint && bun run typecheck && bun run test", "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:changelog-stubs", - "lint:release-notes": "bun run scripts/check-release-notes.ts", "lint:changelog-stubs": "bun run scripts/check-changelog-stubs.ts", "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .", "docs:snippets": "bun run scripts/generate-doc-snippets.ts", @@ -49,7 +50,7 @@ "format:check": "oxfmt --check .", "pull:references": "bun run scripts/pull-references.ts", "changeset": "changeset", - "changeset:version": "changeset version && bun install --lockfile-only", + "changeset:version": "changeset version && bun install --lockfile-only && oxfmt .", "release:check": "bun run --cwd apps/cli typecheck && bun run test:release:bootstrap && bun run release:publish:dry-run", "release:pre:enter": "changeset pre enter beta", "release:pre:exit": "changeset pre exit", @@ -66,6 +67,7 @@ }, "dependencies": {}, "devDependencies": { + "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.30.0", "@effect/language-service": "^0.85.1", "@effect/tsgo": "^0.5.2", diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index 41837c6dc..af304ac8a 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,6 +1 @@ -# @executor-js/app changelog - -This file exists for `changesets/action@v1` compatibility (it reads every -workspace package's `CHANGELOG.md` to build the Version Packages PR). -Canonical user-facing release notes are at `apps/cli/release-notes/next.md` -and on the GitHub Releases page. +# @executor-js/app diff --git a/packages/app/src/routeTree.gen.ts b/packages/app/src/routeTree.gen.ts index 706fc9d11..56432ab7a 100644 --- a/packages/app/src/routeTree.gen.ts +++ b/packages/app/src/routeTree.gen.ts @@ -12,12 +12,11 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ToolsRouteImport } from './routes/tools' import { Route as SecretsRouteImport } from './routes/secrets' import { Route as PoliciesRouteImport } from './routes/policies' -import { Route as ConnectionsRouteImport } from './routes/connections' import { Route as IndexRouteImport } from './routes/index' -import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' -import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' +import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace' import { Route as PluginsPluginIdSplatRouteImport } from './routes/plugins.$pluginId.$' +import { Route as IntegrationsAddPluginKeyRouteImport } from './routes/integrations.add.$pluginKey' const ToolsRoute = ToolsRouteImport.update({ id: '/tools', @@ -34,29 +33,19 @@ const PoliciesRoute = PoliciesRouteImport.update({ path: '/policies', getParentRoute: () => rootRouteImport, } as any) -const ConnectionsRoute = ConnectionsRouteImport.update({ - id: '/connections', - path: '/connections', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) -const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ - id: '/sources/$namespace', - path: '/sources/$namespace', - getParentRoute: () => rootRouteImport, -} as any) const ResumeExecutionIdRoute = ResumeExecutionIdRouteImport.update({ id: '/resume/$executionId', path: '/resume/$executionId', getParentRoute: () => rootRouteImport, } as any) -const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ - id: '/sources/add/$pluginKey', - path: '/sources/add/$pluginKey', +const IntegrationsNamespaceRoute = IntegrationsNamespaceRouteImport.update({ + id: '/integrations/$namespace', + path: '/integrations/$namespace', getParentRoute: () => rootRouteImport, } as any) const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({ @@ -64,87 +53,86 @@ const PluginsPluginIdSplatRoute = PluginsPluginIdSplatRouteImport.update({ path: '/plugins/$pluginId/$', getParentRoute: () => rootRouteImport, } as any) +const IntegrationsAddPluginKeyRoute = + IntegrationsAddPluginKeyRouteImport.update({ + id: '/integrations/add/$pluginKey', + path: '/integrations/add/$pluginKey', + getParentRoute: () => rootRouteImport, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/resume/$executionId': typeof ResumeExecutionIdRoute - '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/resume/$executionId': typeof ResumeExecutionIdRoute - '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/connections': typeof ConnectionsRoute '/policies': typeof PoliciesRoute '/secrets': typeof SecretsRoute '/tools': typeof ToolsRoute + '/integrations/$namespace': typeof IntegrationsNamespaceRoute '/resume/$executionId': typeof ResumeExecutionIdRoute - '/sources/$namespace': typeof SourcesNamespaceRoute + '/integrations/add/$pluginKey': typeof IntegrationsAddPluginKeyRoute '/plugins/$pluginId/$': typeof PluginsPluginIdSplatRoute - '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/connections' | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/resume/$executionId' - | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' - | '/sources/add/$pluginKey' fileRoutesByTo: FileRoutesByTo to: | '/' - | '/connections' | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/resume/$executionId' - | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' - | '/sources/add/$pluginKey' id: | '__root__' | '/' - | '/connections' | '/policies' | '/secrets' | '/tools' + | '/integrations/$namespace' | '/resume/$executionId' - | '/sources/$namespace' + | '/integrations/add/$pluginKey' | '/plugins/$pluginId/$' - | '/sources/add/$pluginKey' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - ConnectionsRoute: typeof ConnectionsRoute PoliciesRoute: typeof PoliciesRoute SecretsRoute: typeof SecretsRoute ToolsRoute: typeof ToolsRoute + IntegrationsNamespaceRoute: typeof IntegrationsNamespaceRoute ResumeExecutionIdRoute: typeof ResumeExecutionIdRoute - SourcesNamespaceRoute: typeof SourcesNamespaceRoute + IntegrationsAddPluginKeyRoute: typeof IntegrationsAddPluginKeyRoute PluginsPluginIdSplatRoute: typeof PluginsPluginIdSplatRoute - SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute } declare module '@tanstack/react-router' { @@ -170,13 +158,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PoliciesRouteImport parentRoute: typeof rootRouteImport } - '/connections': { - id: '/connections' - path: '/connections' - fullPath: '/connections' - preLoaderRoute: typeof ConnectionsRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -184,13 +165,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/sources/$namespace': { - id: '/sources/$namespace' - path: '/sources/$namespace' - fullPath: '/sources/$namespace' - preLoaderRoute: typeof SourcesNamespaceRouteImport - parentRoute: typeof rootRouteImport - } '/resume/$executionId': { id: '/resume/$executionId' path: '/resume/$executionId' @@ -198,11 +172,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResumeExecutionIdRouteImport parentRoute: typeof rootRouteImport } - '/sources/add/$pluginKey': { - id: '/sources/add/$pluginKey' - path: '/sources/add/$pluginKey' - fullPath: '/sources/add/$pluginKey' - preLoaderRoute: typeof SourcesAddPluginKeyRouteImport + '/integrations/$namespace': { + id: '/integrations/$namespace' + path: '/integrations/$namespace' + fullPath: '/integrations/$namespace' + preLoaderRoute: typeof IntegrationsNamespaceRouteImport parentRoute: typeof rootRouteImport } '/plugins/$pluginId/$': { @@ -212,19 +186,25 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PluginsPluginIdSplatRouteImport parentRoute: typeof rootRouteImport } + '/integrations/add/$pluginKey': { + id: '/integrations/add/$pluginKey' + path: '/integrations/add/$pluginKey' + fullPath: '/integrations/add/$pluginKey' + preLoaderRoute: typeof IntegrationsAddPluginKeyRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - ConnectionsRoute: ConnectionsRoute, PoliciesRoute: PoliciesRoute, SecretsRoute: SecretsRoute, ToolsRoute: ToolsRoute, + IntegrationsNamespaceRoute: IntegrationsNamespaceRoute, ResumeExecutionIdRoute: ResumeExecutionIdRoute, - SourcesNamespaceRoute: SourcesNamespaceRoute, + IntegrationsAddPluginKeyRoute: IntegrationsAddPluginKeyRoute, PluginsPluginIdSplatRoute: PluginsPluginIdSplatRoute, - SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/app/src/routes/__root.tsx b/packages/app/src/routes/__root.tsx index 5d6fd4eba..8e50c269f 100644 --- a/packages/app/src/routes/__root.tsx +++ b/packages/app/src/routes/__root.tsx @@ -1,12 +1,8 @@ -import React from "react"; import { createRootRoute } from "@tanstack/react-router"; import { ExecutorProvider } from "@executor-js/react/api/provider"; -import { useExecutorServerConnection } from "@executor-js/react/api/server-connection"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; -import { Button } from "@executor-js/react/components/button"; import { Toaster } from "@executor-js/react/components/sonner"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; -import { ServerConnectionMenu } from "../web/server-connection-menu"; import { Shell } from "../web/shell"; export const Route = createRootRoute({ @@ -15,7 +11,7 @@ export const Route = createRootRoute({ function RootComponent() { return ( - }> + @@ -23,45 +19,3 @@ function RootComponent() { ); } - -function ShellConnectionError() { - const connection = useExecutorServerConnection(); - return ( -
- - -
-
-
- -
-

- Server unavailable -

-

- Could not connect to Executor -

-

- The selected server did not answer the initial scope request. Switch servers or retry - this connection. -

- - {connection.origin} - - -
-
-
- ); -} diff --git a/packages/app/src/routes/connections.tsx b/packages/app/src/routes/connections.tsx deleted file mode 100644 index ae9f0af5a..000000000 --- a/packages/app/src/routes/connections.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ConnectionsPage } from "@executor-js/react/pages/connections"; - -export const Route = createFileRoute("/connections")({ - component: () => , -}); diff --git a/packages/app/src/routes/index.tsx b/packages/app/src/routes/index.tsx index 01273b87a..2d57f82f0 100644 --- a/packages/app/src/routes/index.tsx +++ b/packages/app/src/routes/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { SourcesPage } from "@executor-js/react/pages/sources"; +import { IntegrationsPage } from "@executor-js/react/pages/integrations"; export const Route = createFileRoute("/")({ - component: SourcesPage, + component: IntegrationsPage, }); diff --git a/packages/app/src/routes/integrations.$namespace.tsx b/packages/app/src/routes/integrations.$namespace.tsx new file mode 100644 index 000000000..49a458104 --- /dev/null +++ b/packages/app/src/routes/integrations.$namespace.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { IntegrationDetailPage } from "@executor-js/react/pages/integration-detail"; + +export const Route = createFileRoute("/integrations/$namespace")({ + component: () => { + const { namespace } = Route.useParams(); + return ; + }, +}); diff --git a/packages/app/src/routes/sources.add.$pluginKey.tsx b/packages/app/src/routes/integrations.add.$pluginKey.tsx similarity index 63% rename from packages/app/src/routes/sources.add.$pluginKey.tsx rename to packages/app/src/routes/integrations.add.$pluginKey.tsx index a1618a00a..baada29f3 100644 --- a/packages/app/src/routes/sources.add.$pluginKey.tsx +++ b/packages/app/src/routes/integrations.add.$pluginKey.tsx @@ -1,6 +1,6 @@ import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; -import { SourcesAddPage } from "@executor-js/react/pages/sources-add"; +import { AddIntegrationPage } from "@executor-js/react/pages/integration-add"; const SearchParams = Schema.toStandardSchemaV1( Schema.Struct({ @@ -9,11 +9,11 @@ const SearchParams = Schema.toStandardSchemaV1( }), ); -export const Route = createFileRoute("/sources/add/$pluginKey")({ +export const Route = createFileRoute("/integrations/add/$pluginKey")({ validateSearch: SearchParams, component: () => { const { pluginKey } = Route.useParams(); const { url, preset } = Route.useSearch(); - return ; + return ; }, }); diff --git a/packages/app/src/routes/secrets.tsx b/packages/app/src/routes/secrets.tsx index cdf46a221..1231489cc 100644 --- a/packages/app/src/routes/secrets.tsx +++ b/packages/app/src/routes/secrets.tsx @@ -1,25 +1,8 @@ -import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { SecretsPage } from "@executor-js/react/pages/secrets"; -// Query params supported by the agent-facing `secrets.create` static tool: -// it builds a URL like `/secrets?name=…&scope=…&secretId=…` and hands -// it to the user. The page opens the add modal pre-filled when any -// prefill field is present so the user only has to type the value. -const SearchParams = Schema.toStandardSchemaV1( - Schema.Struct({ - name: Schema.optional(Schema.String), - secretId: Schema.optional(Schema.String), - provider: Schema.optional(Schema.String), - scope: Schema.optional(Schema.String), - }), -); - +// v2: the former "secrets" surface is now the credential-providers view. Bare +// secrets / scope prefill no longer exist (a connection IS the credential). export const Route = createFileRoute("/secrets")({ - validateSearch: SearchParams, - component: () => { - const { name, secretId, provider, scope } = Route.useSearch(); - const hasPrefill = name != null || secretId != null; - return ; - }, + component: () => , }); diff --git a/packages/app/src/routes/sources.$namespace.tsx b/packages/app/src/routes/sources.$namespace.tsx deleted file mode 100644 index 2bcdcce73..000000000 --- a/packages/app/src/routes/sources.$namespace.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { SourceDetailPage } from "@executor-js/react/pages/source-detail"; - -export const Route = createFileRoute("/sources/$namespace")({ - component: () => { - const { namespace } = Route.useParams(); - return ; - }, -}); diff --git a/packages/app/src/web/shell.tsx b/packages/app/src/web/shell.tsx index cdb65e62b..fd16b2d90 100644 --- a/packages/app/src/web/shell.tsx +++ b/packages/app/src/web/shell.tsx @@ -4,18 +4,17 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; +import type { Integration } from "@executor-js/sdk/shared"; import { - connectionsAtom, - sourcesAtom, - sourcesOptimisticAtom, - toolsAtom, + integrationsAtom, + integrationsOptimisticAtom, + toolsAllAtom, } from "@executor-js/react/api/atoms"; -import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; -import { sourcePresetIconUrl } from "@executor-js/react/components/source-favicon"; -import { SourceIconWithAccount } from "@executor-js/react/components/source-icon-with-account"; +import { integrationPresetIconUrl } from "@executor-js/react/components/integration-favicon"; +import { IntegrationIconWithAccount } from "@executor-js/react/components/integration-icon-with-account"; import { CommandPalette } from "@executor-js/react/components/command-palette"; -import { useClientPlugins, useSourcePlugins } from "@executor-js/sdk/client"; +import { useClientPlugins, useIntegrationPlugins } from "@executor-js/sdk/client"; import { ServerConnectionMenu } from "./server-connection-menu"; // ── Env ───────────────────────────────────────────────────────────────── @@ -270,39 +269,35 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { ); } -// ── SourceList ─────────────────────────────────────────────────────────── +// ── IntegrationList ─────────────────────────────────────────────────────────── -function SourceList(props: { pathname: string; onNavigate?: () => void }) { - const scopeId = useScope(); - const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); - const connectionsResult = useAtomValue(connectionsAtom(scopeId)); - const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; - const sourcePlugins = useSourcePlugins(); +function IntegrationList(props: { pathname: string; onNavigate?: () => void }) { + const integrations = useAtomValue(integrationsOptimisticAtom); + const integrationPlugins = useIntegrationPlugins(); - return AsyncResult.match(sources, { + return AsyncResult.match(integrations, { onInitial: () =>
Loading…
, onFailure: () => ( -
No sources yet
+
No integrations yet
), - onSuccess: ({ value }) => + onSuccess: ({ value }: { readonly value: readonly Integration[] }) => value.length === 0 ? (
- No sources yet + No integrations yet
) : (
- {value.map((s) => { - const detailPath = `/sources/${s.id}`; + {value.map((integration: Integration) => { + const slug = String(integration.slug); + const name = integration.description || slug; + const detailPath = `/integrations/${slug}`; const active = props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); - const connection = connections.find((candidate) => - s.connectionIds?.includes(candidate.id), - ); return ( void }) { : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", ].join(" ")} > - - {s.name} + {name} - {s.kind} + {integration.kind} ); @@ -330,28 +326,6 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { }); } -// ── ScopeLabel ─────────────────────────────────────────────────────────── - -function ScopeLabel() { - const { name } = useScopeInfo(); - // Show just the last folder name, with full path as tooltip - const parts = name.replace(/[/\\]+$/, "").split(/[/\\]/); - const folder = parts[parts.length - 1] || name; - - return ( -
- - - - {folder} -
- ); -} - // ── SidebarContent ─────────────────────────────────────────────────────── function SidebarContent(props: { @@ -364,7 +338,6 @@ function SidebarContent(props: { }) { const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; - const isConnections = props.pathname === "/connections"; const isPolicies = props.pathname === "/policies"; return ( @@ -384,14 +357,7 @@ function SidebarContent(props: { )}
- + {props.updateAvailable && props.latestVersion && ( @@ -447,9 +413,8 @@ function SidebarContent(props: { export function Shell() { const location = useLocation(); const pathname = location.pathname; - const scopeId = useScope(); - const refreshSources = useAtomRefresh(sourcesAtom(scopeId)); - const refreshTools = useAtomRefresh(toolsAtom(scopeId)); + const refreshSources = useAtomRefresh(integrationsAtom); + const refreshTools = useAtomRefresh(toolsAllAtom); const { latestVersion, updateAvailable, channel } = useLatestVersion(VITE_APP_VERSION); const lastPathname = useRef(pathname); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); diff --git a/packages/core/api/CHANGELOG.md b/packages/core/api/CHANGELOG.md index bff41a602..61e62b3d9 100644 --- a/packages/core/api/CHANGELOG.md +++ b/packages/core/api/CHANGELOG.md @@ -1,4 +1,28 @@ -# @executor-js/api changelog +# @executor-js/api -This file exists for Changesets release workflow compatibility. -Canonical user-facing release notes are published on GitHub Releases. +## 1.4.24 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/execution@1.5.2 + - @executor-js/host-mcp@1.4.4 + +## 1.4.23 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + - @executor-js/execution@1.5.1 + - @executor-js/host-mcp@1.4.4 + +## 1.4.22 + +### Patch Changes + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]: + - @executor-js/sdk@1.5.0 + - @executor-js/execution@1.5.0 + - @executor-js/host-mcp@1.4.4 diff --git a/packages/core/api/package.json b/packages/core/api/package.json index a7c6bde8b..b2138ece5 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/api", - "version": "1.4.21", + "version": "1.4.24", "private": true, "type": "module", "exports": { diff --git a/packages/core/api/src/api.ts b/packages/core/api/src/api.ts index 3a9137a01..22d531bc8 100644 --- a/packages/core/api/src/api.ts +++ b/packages/core/api/src/api.ts @@ -2,21 +2,19 @@ import { HttpApi, OpenApi } from "effect/unstable/httpapi"; import type { HttpApiGroup } from "effect/unstable/httpapi"; import { ToolsApi } from "./tools/api"; -import { SourcesApi } from "./sources/api"; -import { SecretsApi } from "./secrets/api"; +import { IntegrationsApi } from "./integrations/api"; import { ConnectionsApi } from "./connections/api"; +import { ProvidersApi } from "./providers/api"; import { ExecutionsApi } from "./executions/api"; -import { ScopeApi } from "./scope/api"; import { OAuthApi } from "./oauth/api"; import { PoliciesApi } from "./policies/api"; export const CoreExecutorApi = HttpApi.make("executor") .add(ToolsApi) - .add(SourcesApi) - .add(SecretsApi) + .add(IntegrationsApi) .add(ConnectionsApi) + .add(ProvidersApi) .add(ExecutionsApi) - .add(ScopeApi) .add(OAuthApi) .add(PoliciesApi) .annotateMerge( diff --git a/packages/core/api/src/client.ts b/packages/core/api/src/client.ts index 89857cbda..fd6c9f41b 100644 --- a/packages/core/api/src/client.ts +++ b/packages/core/api/src/client.ts @@ -1,10 +1,9 @@ export { ExecutorApi, CoreExecutorApi } from "./api"; export { ToolsApi } from "./tools/api"; -export { SourcesApi } from "./sources/api"; -export { SecretsApi } from "./secrets/api"; +export { IntegrationsApi } from "./integrations/api"; export { ConnectionsApi } from "./connections/api"; +export { ProvidersApi } from "./providers/api"; export { ExecutionsApi } from "./executions/api"; -export { ScopeApi } from "./scope/api"; export { OAuthApi } from "./oauth/api"; export { PoliciesApi } from "./policies/api"; export { diff --git a/packages/core/api/src/connections/api.ts b/packages/core/api/src/connections/api.ts index 3bca0a444..c62ac8e39 100644 --- a/packages/core/api/src/connections/api.ts +++ b/packages/core/api/src/connections/api.ts @@ -1,97 +1,173 @@ +// --------------------------------------------------------------------------- +// Connections HTTP API — the v2 credential surface. +// +// A connection IS the credential: owner-scoped (org | user), bound 1:1 to an +// integration, resolving its value through a `CredentialProvider`. Identified by +// `(owner, integration, name)`. No scope segments, no token-secret-ids, no +// identity-override-by-scope — those v1 concepts are gone. +// --------------------------------------------------------------------------- + import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { Schema } from "effect"; +import { Predicate, Schema } from "effect"; import { - ConnectionId, - ConnectionIdentityOverride, - ConnectionInUseError, + AuthTemplateSlug, + ConnectionAddress, + ConnectionName, ConnectionNotFoundError, + CredentialProviderNotRegisteredError, + IntegrationNotFoundError, + IntegrationSlug, InternalError, - ScopeId, - Usage, + InvalidConnectionInputError, + OAuthClientSlug, + Owner, + ProviderItemId, + ProviderKey, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- -// Params +// Params — a connection is identified by (owner, integration, name). // --------------------------------------------------------------------------- -const ScopeParams = { scopeId: ScopeId }; -const ConnectionParams = { scopeId: ScopeId, connectionId: ConnectionId }; +const ConnectionParams = { + owner: Owner, + integration: IntegrationSlug, + name: ConnectionName, +}; // --------------------------------------------------------------------------- -// Response schemas +// Response schemas — mirrors the SDK's `Connection`. // --------------------------------------------------------------------------- -const ConnectionRefResponse = Schema.Struct({ - id: ConnectionId, - scopeId: ScopeId, - provider: Schema.String, +const ConnectionResponse = Schema.Struct({ + owner: Owner, + name: ConnectionName, + integration: IntegrationSlug, + template: AuthTemplateSlug, + provider: ProviderKey, + address: ConnectionAddress, identityLabel: Schema.NullOr(Schema.String), expiresAt: Schema.NullOr(Schema.Number), + // The OAuth app that minted this connection (its `oauth_client` slug), or null + // for static credentials. Lets the UI map a connection back to its app. Just a + // slug — never a secret. + oauthClient: Schema.NullOr(OAuthClientSlug), + oauthClientOwner: Schema.NullOr(Owner), oauthScope: Schema.NullOr(Schema.String), - identityOverride: Schema.NullOr(ConnectionIdentityOverride), - createdAt: Schema.Number, - updatedAt: Schema.Number, }); -export const ConnectionIdentityResponse = Schema.Struct({ - status: Schema.Literals(["available", "unavailable", "reauth_required", "error"]), - source: Schema.Literals(["detected", "manual", "mixed", "unknown"]), - subject: Schema.NullOr(Schema.String), - email: Schema.NullOr(Schema.String), - emailVerified: Schema.NullOr(Schema.Boolean), - name: Schema.NullOr(Schema.String), - username: Schema.NullOr(Schema.String), - picture: Schema.NullOr(Schema.String), - message: Schema.NullOr(Schema.String), +const ToolResponse = Schema.Struct({ + address: Schema.String, + owner: Owner, + integration: IntegrationSlug, + connection: ConnectionName, + name: Schema.String, + pluginId: Schema.String, + description: Schema.String, }); -export type ConnectionIdentityResponse = typeof ConnectionIdentityResponse.Type; -const UpdateConnectionIdentityPayload = Schema.Struct({ - identityOverride: Schema.NullOr(ConnectionIdentityOverride), +// --------------------------------------------------------------------------- +// Payload schemas +// --------------------------------------------------------------------------- + +// A connection picks exactly one origin: a single pasted `value` (sugar for the +// `token` input), a `values` map (one per named input, e.g. both of Datadog's +// keys), or an external `from` reference. +const CommonCreateFields = { + owner: Owner, + name: ConnectionName, + integration: IntegrationSlug, + template: AuthTemplateSlug, + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), +} as const; + +const CreateConnectionPayload = Schema.Struct({ + ...CommonCreateFields, + value: Schema.optional(Schema.String), + values: Schema.optional(Schema.Record(Schema.String, Schema.String)), + from: Schema.optional( + Schema.Struct({ + provider: ProviderKey, + id: ProviderItemId, + }), + ), +}).check( + Schema.makeFilter((payload) => + [payload.value, payload.values, payload.from].filter(Predicate.isNotUndefined).length === 1 + ? undefined + : "Expected exactly one credential origin", + ), +); + +// --------------------------------------------------------------------------- +// Query — optional list filters. +// --------------------------------------------------------------------------- + +const ListConnectionsQuery = Schema.Struct({ + integration: Schema.optional(IntegrationSlug), + owner: Schema.optional(Owner), }); // --------------------------------------------------------------------------- -// Group +// Error schemas with HTTP status annotations // --------------------------------------------------------------------------- -const ConnectionInUse = ConnectionInUseError.annotate({ httpApiStatus: 409 }); -const ConnectionNotFound = ConnectionNotFoundError.annotate({ httpApiStatus: 404 }); +const ConnectionNotFound = ConnectionNotFoundError.annotate({ + httpApiStatus: 404, +}); +const IntegrationNotFound = IntegrationNotFoundError.annotate({ + httpApiStatus: 404, +}); +const CredentialProviderNotRegistered = CredentialProviderNotRegisteredError.annotate({ + httpApiStatus: 409, +}); +const InvalidConnectionInput = InvalidConnectionInputError.annotate({ + httpApiStatus: 400, +}); + +// --------------------------------------------------------------------------- +// Group +// --------------------------------------------------------------------------- export const ConnectionsApi = HttpApiGroup.make("connections") .add( - HttpApiEndpoint.get("list", "/scopes/:scopeId/connections", { - params: ScopeParams, - success: Schema.Array(ConnectionRefResponse), + HttpApiEndpoint.get("list", "/connections", { + query: ListConnectionsQuery, + success: Schema.Array(ConnectionResponse), error: InternalError, }), ) .add( - HttpApiEndpoint.delete("remove", "/scopes/:scopeId/connections/:connectionId", { - params: ConnectionParams, - success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, ConnectionInUse], + HttpApiEndpoint.post("create", "/connections", { + payload: CreateConnectionPayload, + success: ConnectionResponse, + error: [ + InternalError, + IntegrationNotFound, + CredentialProviderNotRegistered, + InvalidConnectionInput, + ], }), ) .add( - HttpApiEndpoint.get("usages", "/scopes/:scopeId/connections/:connectionId/usages", { + HttpApiEndpoint.get("get", "/connections/:owner/:integration/:name", { params: ConnectionParams, - success: Schema.Array(Usage), - error: InternalError, + success: ConnectionResponse, + error: [InternalError, ConnectionNotFound], }), ) .add( - HttpApiEndpoint.get("identity", "/scopes/:scopeId/connections/:connectionId/identity", { + HttpApiEndpoint.delete("remove", "/connections/:owner/:integration/:name", { params: ConnectionParams, - success: ConnectionIdentityResponse, - error: InternalError, + success: Schema.Struct({ removed: Schema.Boolean }), + error: [InternalError, ConnectionNotFound], }), ) .add( - HttpApiEndpoint.patch("updateIdentity", "/scopes/:scopeId/connections/:connectionId/identity", { + HttpApiEndpoint.post("refresh", "/connections/:owner/:integration/:name/refresh", { params: ConnectionParams, - payload: UpdateConnectionIdentityPayload, - success: ConnectionRefResponse, - error: [InternalError, ConnectionNotFound], + success: Schema.Array(ToolResponse), + error: [InternalError, ConnectionNotFound, IntegrationNotFound], }), ); diff --git a/packages/core/api/src/handlers/connection-identity.test.ts b/packages/core/api/src/handlers/connection-identity.test.ts deleted file mode 100644 index a1a7f26c5..000000000 --- a/packages/core/api/src/handlers/connection-identity.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Ref } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; - -import { - CreateConnectionInput, - OAUTH2_PROVIDER_KEY, - TokenMaterial, - createExecutor, -} from "@executor-js/sdk"; -import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk/shared"; -import { makeTestConfig, memorySecretsPlugin, serveTestHttpApp } from "@executor-js/sdk/testing"; - -import { lookupOidcConnectionIdentity, readConnectionIdentity } from "./connection-identity"; - -type CapturedRequest = { - readonly method: string; - readonly url: string; - readonly headers: Readonly>; -}; - -type Handler = (request: CapturedRequest, baseUrl: string) => HttpServerResponse.HttpServerResponse; - -const notFound = (): HttpServerResponse.HttpServerResponse => - HttpServerResponse.empty({ status: 404 }); - -const serveOidcFixture = (handler: Handler) => - Effect.gen(function* () { - const requests = yield* Ref.make([]); - const baseUrlRef = { value: "" }; - const server = yield* serveTestHttpApp((request) => - Effect.gen(function* () { - const captured = { - method: request.method, - url: request.url ?? "/", - headers: request.headers, - }; - yield* Ref.update(requests, (all) => [...all, captured]); - return handler(captured, baseUrlRef.value); - }), - ); - baseUrlRef.value = server.baseUrl; - - return { - baseUrl: server.baseUrl, - requests: Ref.get(requests), - } as const; - }); - -const withOidcFixture = ( - handler: Handler, - use: (fixture: { - readonly baseUrl: string; - readonly requests: Effect.Effect; - }) => Effect.Effect, -) => - Effect.scoped( - Effect.gen(function* () { - const fixture = yield* serveOidcFixture(handler); - return yield* use(fixture); - }), - ); - -describe("lookupOidcConnectionIdentity", () => { - it.effect("reads normalized account claims from OIDC userinfo", () => - withOidcFixture( - (request, baseUrl) => { - if (request.url === "/.well-known/openid-configuration") { - return HttpServerResponse.jsonUnsafe({ - issuer: baseUrl, - userinfo_endpoint: `${baseUrl}/userinfo`, - }); - } - if (request.url === "/userinfo") { - return HttpServerResponse.jsonUnsafe({ - sub: "account-123", - email: "rhys@example.com", - email_verified: true, - name: "Rhys Sullivan", - preferred_username: "rhys", - picture: "https://example.com/avatar.png", - }); - } - return notFound(); - }, - ({ baseUrl, requests }) => - Effect.gen(function* () { - const identity = yield* lookupOidcConnectionIdentity({ - issuerUrl: baseUrl, - accessToken: "token-abc", - }); - const seenRequests = yield* requests; - - expect(identity).toEqual({ - status: "available", - source: "detected", - subject: "account-123", - email: "rhys@example.com", - emailVerified: true, - name: "Rhys Sullivan", - username: "rhys", - picture: "https://example.com/avatar.png", - message: null, - }); - expect(seenRequests.map((request) => request.url)).toEqual([ - "/.well-known/openid-configuration", - "/userinfo", - ]); - expect(seenRequests[1]?.headers.authorization).toBe("Bearer token-abc"); - }), - ), - ); - - it.effect("returns unavailable when OIDC metadata has no userinfo endpoint", () => - withOidcFixture( - (request, baseUrl) => { - if (request.url === "/.well-known/openid-configuration") { - return HttpServerResponse.jsonUnsafe({ issuer: baseUrl }); - } - return notFound(); - }, - ({ baseUrl, requests }) => - Effect.gen(function* () { - const identity = yield* lookupOidcConnectionIdentity({ - issuerUrl: baseUrl, - accessToken: "token-abc", - }); - const seenRequests = yield* requests; - - expect(identity).toEqual({ - status: "unavailable", - source: "unknown", - subject: null, - email: null, - emailVerified: null, - name: null, - username: null, - picture: null, - message: "This connection does not advertise OIDC userinfo", - }); - expect(seenRequests.map((request) => request.url)).toEqual([ - "/.well-known/openid-configuration", - ]); - }), - ), - ); - - it.effect("marks the connection as needing reauth when userinfo rejects the token", () => - withOidcFixture( - (request, baseUrl) => { - if (request.url === "/.well-known/openid-configuration") { - return HttpServerResponse.jsonUnsafe({ - issuer: baseUrl, - userinfo_endpoint: `${baseUrl}/userinfo`, - }); - } - if (request.url === "/userinfo") { - return HttpServerResponse.jsonUnsafe({ error: "invalid_token" }, { status: 401 }); - } - return notFound(); - }, - ({ baseUrl }) => - Effect.gen(function* () { - const identity = yield* lookupOidcConnectionIdentity({ - issuerUrl: baseUrl, - accessToken: "expired-token", - }); - - expect(identity).toEqual({ - status: "reauth_required", - source: "unknown", - subject: null, - email: null, - emailVerified: null, - name: null, - username: null, - picture: null, - message: "OIDC userinfo rejected the access token", - }); - }), - ), - ); - - it.effect("returns unavailable when userinfo is outside the token's granted scopes", () => - withOidcFixture( - (request, baseUrl) => { - if (request.url === "/.well-known/openid-configuration") { - return HttpServerResponse.jsonUnsafe({ - issuer: baseUrl, - userinfo_endpoint: `${baseUrl}/userinfo`, - }); - } - if (request.url === "/userinfo") { - return HttpServerResponse.jsonUnsafe({ error: "insufficient_scope" }, { status: 403 }); - } - return notFound(); - }, - ({ baseUrl }) => - Effect.gen(function* () { - const identity = yield* lookupOidcConnectionIdentity({ - issuerUrl: baseUrl, - accessToken: "limited-token", - }); - - expect(identity).toEqual({ - status: "unavailable", - source: "unknown", - subject: null, - email: null, - emailVerified: null, - name: null, - username: null, - picture: null, - message: "OIDC userinfo is not permitted by this token", - }); - }), - ), - ); -}); - -describe("readConnectionIdentity", () => { - it.effect("does not call OIDC userinfo for OAuth connections without identity scopes", () => - Effect.gen(function* () { - const userScope = ScopeId.make("test-scope"); - const connectionId = ConnectionId.make("gmail"); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin()] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: userScope, - provider: OAUTH2_PROVIDER_KEY, - identityLabel: "Gmail API OAuth", - accessToken: TokenMaterial.make({ - secretId: SecretId.make("gmail.access-token"), - name: "Gmail access token", - value: "gmail-token", - }), - refreshToken: null, - expiresAt: null, - oauthScope: "https://www.googleapis.com/auth/gmail.readonly", - providerState: { - kind: "authorization-code", - tokenEndpoint: "https://oauth2.googleapis.com/token", - issuerUrl: "https://accounts.google.com", - clientIdSecretId: "client-id", - clientIdSecretScopeId: null, - clientSecretSecretId: null, - clientSecretSecretScopeId: null, - clientAuth: "body", - scopes: ["https://www.googleapis.com/auth/gmail.readonly"], - scope: "https://www.googleapis.com/auth/gmail.readonly", - }, - }), - ); - - const identity = yield* readConnectionIdentity({ - executor, - scopeId: userScope, - connectionId, - }); - - expect(identity).toEqual({ - status: "unavailable", - source: "unknown", - subject: null, - email: null, - emailVerified: null, - name: null, - username: null, - picture: null, - message: "Connection was not granted OIDC identity scopes", - }); - }), - ); - - it.effect("uses manual account info when OIDC identity is unavailable", () => - Effect.gen(function* () { - const userScope = ScopeId.make("test-scope"); - const connectionId = ConnectionId.make("gmail"); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin()] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: userScope, - provider: OAUTH2_PROVIDER_KEY, - identityLabel: "Gmail API OAuth", - accessToken: TokenMaterial.make({ - secretId: SecretId.make("manual.access-token"), - name: "Manual access token", - value: "manual-token", - }), - refreshToken: null, - expiresAt: null, - oauthScope: "https://www.googleapis.com/auth/gmail.readonly", - providerState: { - kind: "authorization-code", - tokenEndpoint: "https://oauth2.googleapis.com/token", - issuerUrl: "https://accounts.google.com", - clientIdSecretId: "client-id", - clientIdSecretScopeId: null, - clientSecretSecretId: null, - clientSecretSecretScopeId: null, - clientAuth: "body", - scopes: ["https://www.googleapis.com/auth/gmail.readonly"], - scope: "https://www.googleapis.com/auth/gmail.readonly", - }, - }), - ); - yield* executor.connections.setIdentityOverride({ - id: connectionId, - targetScope: userScope, - identityOverride: { - displayName: "Manual Account", - email: "manual@example.com", - avatarUrl: "https://example.com/manual.png", - }, - }); - - const identity = yield* readConnectionIdentity({ - executor, - scopeId: userScope, - connectionId, - }); - - expect(identity).toEqual({ - status: "available", - source: "manual", - subject: null, - email: "manual@example.com", - emailVerified: null, - name: "Manual Account", - username: null, - picture: "https://example.com/manual.png", - message: null, - }); - }), - ); -}); diff --git a/packages/core/api/src/handlers/connection-identity.ts b/packages/core/api/src/handlers/connection-identity.ts deleted file mode 100644 index 2ab903600..000000000 --- a/packages/core/api/src/handlers/connection-identity.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { Data, Duration, Effect, Exit, Option, Predicate, Schema, type Layer } from "effect"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; - -import { OAUTH2_PROVIDER_KEY, OAuthProviderStateSchema, type Executor } from "@executor-js/sdk"; -import { - OAUTH2_DEFAULT_TIMEOUT_MS, - assertSupportedOAuthEndpointUrl, -} from "@executor-js/sdk/host-internal"; -import type { ConnectionId, ScopeId } from "@executor-js/sdk/shared"; - -import type { ConnectionIdentityResponse } from "../connections/api"; - -const OidcDiscoveryMetadata = Schema.Struct({ - issuer: Schema.optional(Schema.String), - userinfo_endpoint: Schema.optional(Schema.String), -}).annotate({ identifier: "OidcDiscoveryMetadata" }); - -const UserInfoClaims = Schema.Struct({ - sub: Schema.optional(Schema.String), - email: Schema.optional(Schema.String), - email_verified: Schema.optional(Schema.Boolean), - name: Schema.optional(Schema.String), - preferred_username: Schema.optional(Schema.String), - picture: Schema.optional(Schema.String), -}).annotate({ identifier: "OidcUserInfoClaims" }); - -const decodeProviderStateOption = Schema.decodeUnknownOption(OAuthProviderStateSchema); -const decodeDiscoveryMetadataJson = Schema.decodeUnknownEffect( - Schema.fromJsonString(OidcDiscoveryMetadata), -); -const decodeUserInfoJson = Schema.decodeUnknownEffect(Schema.fromJsonString(UserInfoClaims)); - -class ConnectionIdentityLookupError extends Data.TaggedError("ConnectionIdentityLookupError")<{ - readonly message: string; - readonly status?: number; - readonly cause?: unknown; -}> {} - -const emptyIdentity = ( - status: ConnectionIdentityResponse["status"], - message: string | null, -): ConnectionIdentityResponse => ({ - status, - source: "unknown", - subject: null, - email: null, - emailVerified: null, - name: null, - username: null, - picture: null, - message, -}); - -const clean = (value: string | undefined): string | null => { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : null; -}; - -const hasOidcIdentityScope = (oauthScope: string | null): boolean => - oauthScope - ?.split(/\s+/) - .some((scope) => scope === "openid" || scope === "profile" || scope === "email") ?? false; - -const applyIdentityOverride = ( - identity: ConnectionIdentityResponse, - override: { - readonly displayName: string | null; - readonly email: string | null; - readonly avatarUrl: string | null; - } | null, -): ConnectionIdentityResponse => { - if (!override) return identity; - const name = clean(override.displayName ?? undefined); - const email = clean(override.email ?? undefined); - const picture = clean(override.avatarUrl ?? undefined); - if (!name && !email && !picture) return identity; - const hasDetected = - identity.status === "available" && - Boolean(identity.name || identity.email || identity.picture || identity.subject); - return { - ...identity, - status: "available", - source: hasDetected ? "mixed" : "manual", - name: name ?? identity.name, - email: email ?? identity.email, - picture: picture ?? identity.picture, - message: hasDetected ? identity.message : null, - }; -}; - -const oidcMetadataUrlFor = (issuer: string): Effect.Effect => - Effect.try({ - try: () => { - assertSupportedOAuthEndpointUrl(issuer, "OIDC issuer URL"); - const issuerUrl = new URL(issuer); - const issuerOrigin = `${issuerUrl.protocol}//${issuerUrl.host}`; - const issuerPath = issuerUrl.pathname.replace(/\/+$/, ""); - const metadataUrl = - issuerPath && issuerPath !== "/" - ? `${issuerOrigin}/.well-known/openid-configuration${issuerPath}` - : `${issuerOrigin}/.well-known/openid-configuration`; - assertSupportedOAuthEndpointUrl(metadataUrl, "OIDC metadata URL"); - return metadataUrl; - }, - catch: (cause) => - new ConnectionIdentityLookupError({ - message: "OIDC issuer URL is not supported", - cause, - }), - }); - -const executeText = ( - request: HttpClientRequest.HttpClientRequest, - options: { - readonly httpClientLayer?: Layer.Layer; - readonly timeoutMs?: number; - }, - message: string, -): Effect.Effect< - { readonly status: number; readonly body: string }, - ConnectionIdentityLookupError -> => - Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute(request).pipe( - Effect.timeoutOrElse({ - duration: Duration.millis(options.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), - orElse: () => - Effect.fail( - new ConnectionIdentityLookupError({ - message, - cause: "timeout", - }), - ), - }), - Effect.mapError((cause) => - Predicate.isTagged("ConnectionIdentityLookupError")(cause) - ? cause - : new ConnectionIdentityLookupError({ message, cause }), - ), - ); - const body = yield* response.text.pipe( - Effect.mapError( - (cause) => - new ConnectionIdentityLookupError({ - message: `${message}: response body could not be read`, - status: response.status, - cause, - }), - ), - ); - return { status: response.status, body }; - }).pipe(Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer)); - -const fetchOidcMetadata = ( - issuer: string, - options: { - readonly httpClientLayer?: Layer.Layer; - readonly timeoutMs?: number; - }, -): Effect.Effect => - Effect.gen(function* () { - const metadataUrl = yield* oidcMetadataUrlFor(issuer); - const response = yield* executeText( - HttpClientRequest.get(metadataUrl).pipe( - HttpClientRequest.setHeader("accept", "application/json"), - ), - options, - "Failed to fetch OIDC metadata", - ); - if (response.status < 200 || response.status >= 300) { - return yield* new ConnectionIdentityLookupError({ - message: `OIDC metadata returned status ${response.status}`, - status: response.status, - }); - } - return yield* decodeDiscoveryMetadataJson(response.body).pipe( - Effect.mapError( - (cause) => - new ConnectionIdentityLookupError({ - message: "OIDC metadata is malformed", - cause, - }), - ), - ); - }); - -export const lookupOidcConnectionIdentity = ( - input: { - readonly issuerUrl: string; - readonly accessToken: string; - }, - options: { - readonly httpClientLayer?: Layer.Layer; - readonly timeoutMs?: number; - } = {}, -): Effect.Effect => - Effect.gen(function* () { - const metadata = yield* fetchOidcMetadata(input.issuerUrl, options).pipe( - Effect.catchTag("ConnectionIdentityLookupError", () => Effect.succeed(null)), - ); - const advertisedUserinfoEndpoint = metadata?.userinfo_endpoint; - if (!advertisedUserinfoEndpoint) { - return emptyIdentity("unavailable", "This connection does not advertise OIDC userinfo"); - } - - const userinfoEndpoint = yield* Effect.try({ - try: () => { - assertSupportedOAuthEndpointUrl(advertisedUserinfoEndpoint, "OIDC userinfo URL"); - return advertisedUserinfoEndpoint; - }, - catch: (cause) => - new ConnectionIdentityLookupError({ - message: "OIDC userinfo URL is not supported", - cause, - }), - }).pipe(Effect.catchTag("ConnectionIdentityLookupError", () => Effect.succeed(null))); - if (!userinfoEndpoint) return emptyIdentity("unavailable", "OIDC userinfo is unavailable"); - - const response = yield* executeText( - HttpClientRequest.get(userinfoEndpoint).pipe( - HttpClientRequest.setHeader("accept", "application/json"), - HttpClientRequest.setHeader("authorization", `Bearer ${input.accessToken}`), - ), - options, - "Failed to fetch OIDC userinfo", - ).pipe( - Effect.catchTag("ConnectionIdentityLookupError", ({ message }) => - Effect.succeed({ status: 0, body: "", message } as const), - ), - ); - if ("message" in response) return emptyIdentity("error", response.message); - if (response.status === 401) { - return emptyIdentity("reauth_required", "OIDC userinfo rejected the access token"); - } - if (response.status === 403) { - return emptyIdentity("unavailable", "OIDC userinfo is not permitted by this token"); - } - if (response.status < 200 || response.status >= 300) { - return emptyIdentity("error", `OIDC userinfo returned status ${response.status}`); - } - - const claims = yield* decodeUserInfoJson(response.body).pipe( - Effect.catch(() => Effect.succeed(null as typeof UserInfoClaims.Type | null)), - ); - if (!claims) return emptyIdentity("error", "OIDC userinfo response is malformed"); - - return { - status: "available", - source: "detected", - subject: clean(claims.sub), - email: clean(claims.email), - emailVerified: claims.email_verified ?? null, - name: clean(claims.name), - username: clean(claims.preferred_username), - picture: clean(claims.picture), - message: null, - }; - }); - -export const readConnectionIdentity = (input: { - readonly executor: Executor; - readonly scopeId: ScopeId; - readonly connectionId: ConnectionId; -}): Effect.Effect => - Effect.gen(function* () { - const connectionExit = yield* Effect.exit( - input.executor.connections.getAtScope(input.connectionId, input.scopeId), - ); - if (Exit.isFailure(connectionExit)) { - return emptyIdentity("error", "Could not read connection metadata"); - } - const connection = connectionExit.value; - if (!connection) return emptyIdentity("unavailable", "Connection was not found"); - const withOverride = (identity: ConnectionIdentityResponse) => - applyIdentityOverride(identity, connection.identityOverride); - if (connection.provider !== OAUTH2_PROVIDER_KEY) { - return withOverride( - emptyIdentity("unavailable", "Only OAuth2 connections can expose account identity"), - ); - } - - const providerState = Option.getOrNull(decodeProviderStateOption(connection.providerState)); - const issuerUrl = - providerState && providerState.kind !== "client-credentials" - ? (providerState.issuerUrl ?? null) - : null; - if (!issuerUrl) { - return withOverride( - emptyIdentity("unavailable", "Connection does not include an OIDC issuer"), - ); - } - if (!hasOidcIdentityScope(connection.oauthScope)) { - return withOverride( - emptyIdentity("unavailable", "Connection was not granted OIDC identity scopes"), - ); - } - - const accessTokenExit = yield* Effect.exit( - input.executor.connections.accessTokenAtScope(input.connectionId, input.scopeId), - ); - if (Exit.isFailure(accessTokenExit)) { - const error = Option.getOrNull(Exit.findErrorOption(accessTokenExit)); - if (error && Predicate.isTagged("ConnectionReauthRequiredError")(error)) { - return withOverride(emptyIdentity("reauth_required", "Connection needs re-authentication")); - } - return withOverride(emptyIdentity("error", "Could not read the connection access token")); - } - - const identity = yield* lookupOidcConnectionIdentity({ - issuerUrl, - accessToken: accessTokenExit.value, - }); - return withOverride(identity); - }); diff --git a/packages/core/api/src/handlers/connections.ts b/packages/core/api/src/handlers/connections.ts index 24c7d9c5a..86295aa3f 100644 --- a/packages/core/api/src/handlers/connections.ts +++ b/packages/core/api/src/handlers/connections.ts @@ -3,84 +3,109 @@ import { Effect } from "effect"; import { capture } from "@executor-js/api"; import { - RemoveConnectionInput, - UpdateConnectionIdentityInput, + ConnectionNotFoundError, + type Connection, type ConnectionRef, + type CreateConnectionInput, + type Tool, } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; import { ExecutorService } from "../services"; -import { readConnectionIdentity } from "./connection-identity"; -const refToResponse = (ref: ConnectionRef) => ({ - id: ref.id, - scopeId: ref.scopeId, - provider: ref.provider, - identityLabel: ref.identityLabel, - expiresAt: ref.expiresAt, - oauthScope: ref.oauthScope, - identityOverride: ref.identityOverride, - createdAt: ref.createdAt.getTime(), - updatedAt: ref.updatedAt.getTime(), +const toResponse = (c: Connection) => ({ + owner: c.owner, + name: c.name, + integration: c.integration, + template: c.template, + provider: c.provider, + address: c.address, + identityLabel: c.identityLabel ?? null, + expiresAt: c.expiresAt ?? null, + oauthClient: c.oauthClient ?? null, + oauthClientOwner: c.oauthClientOwner ?? null, + oauthScope: c.oauthScope ?? null, +}); + +const toolToResponse = (t: Tool) => ({ + address: String(t.address), + owner: t.owner, + integration: t.integration, + connection: t.connection, + name: String(t.name), + pluginId: t.pluginId, + description: t.description, }); export const ConnectionsHandlers = HttpApiBuilder.group(ExecutorApi, "connections", (handlers) => handlers - .handle("list", () => + .handle("list", ({ query }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - const refs = yield* executor.connections.list(); - return refs.map(refToResponse); + const connections = yield* executor.connections.list({ + integration: query.integration, + owner: query.owner, + }); + return connections.map(toResponse); }), ), ) - .handle("remove", ({ params: path }) => + .handle("create", ({ payload }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - yield* executor.connections.remove( - RemoveConnectionInput.make({ - id: path.connectionId, - targetScope: path.scopeId, - }), - ); - return { removed: true }; + // The payload is the discriminated `CreateConnectionInput` union + // (`{ value }` | `{ values }` | `{ from }`); pass it through verbatim. + const created = yield* executor.connections.create(payload as CreateConnectionInput); + return toResponse(created); }), ), ) - .handle("usages", ({ params: path }) => + .handle("get", ({ params: path }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - return yield* executor.connections.usages(path.connectionId); + const ref: ConnectionRef = { + owner: path.owner, + integration: path.integration, + name: path.name, + }; + const connection = yield* executor.connections.get(ref); + if (connection === null) { + return yield* new ConnectionNotFoundError({ + owner: path.owner, + integration: path.integration, + name: path.name, + }); + } + return toResponse(connection); }), ), ) - .handle("identity", ({ params: path }) => + .handle("remove", ({ params: path }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - return yield* readConnectionIdentity({ - executor, - scopeId: path.scopeId, - connectionId: path.connectionId, + yield* executor.connections.remove({ + owner: path.owner, + integration: path.integration, + name: path.name, }); + return { removed: true }; }), ), ) - .handle("updateIdentity", ({ params: path, payload }) => + .handle("refresh", ({ params: path }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - const ref = yield* executor.connections.setIdentityOverride( - UpdateConnectionIdentityInput.make({ - id: path.connectionId, - targetScope: path.scopeId, - identityOverride: payload.identityOverride, - }), - ); - return refToResponse(ref); + const tools = yield* executor.connections.refresh({ + owner: path.owner, + integration: path.integration, + name: path.name, + }); + return tools.map(toolToResponse); }), ), ), diff --git a/packages/core/api/src/handlers/index.ts b/packages/core/api/src/handlers/index.ts index 06b5af670..6a21313bf 100644 --- a/packages/core/api/src/handlers/index.ts +++ b/packages/core/api/src/handlers/index.ts @@ -1,29 +1,26 @@ import { Layer } from "effect"; import { ToolsHandlers } from "./tools"; -import { SourcesHandlers } from "./sources"; -import { SecretsHandlers } from "./secrets"; +import { IntegrationsHandlers } from "./integrations"; import { ConnectionsHandlers } from "./connections"; -import { ScopeHandlers } from "./scope"; +import { ProvidersHandlers } from "./providers"; import { ExecutionsHandlers } from "./executions"; import { OAuthHandlers } from "./oauth"; import { PoliciesHandlers } from "./policies"; export { ToolsHandlers } from "./tools"; -export { SourcesHandlers } from "./sources"; -export { SecretsHandlers } from "./secrets"; +export { IntegrationsHandlers } from "./integrations"; export { ConnectionsHandlers } from "./connections"; -export { ScopeHandlers } from "./scope"; +export { ProvidersHandlers } from "./providers"; export { ExecutionsHandlers } from "./executions"; export { OAuthHandlers } from "./oauth"; export { PoliciesHandlers } from "./policies"; export const CoreHandlers = Layer.mergeAll( ToolsHandlers, - SourcesHandlers, - SecretsHandlers, + IntegrationsHandlers, ConnectionsHandlers, - ScopeHandlers, + ProvidersHandlers, ExecutionsHandlers, OAuthHandlers, PoliciesHandlers, diff --git a/packages/core/api/src/handlers/integrations.ts b/packages/core/api/src/handlers/integrations.ts new file mode 100644 index 000000000..fb4d22c9d --- /dev/null +++ b/packages/core/api/src/handlers/integrations.ts @@ -0,0 +1,81 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; +import { IntegrationNotFoundError, type Integration } from "@executor-js/sdk"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; +import { capture } from "@executor-js/api"; + +const toResponse = (i: Integration) => ({ + slug: i.slug, + description: i.description, + kind: i.kind, + canRemove: i.canRemove, + canRefresh: i.canRefresh, + authMethods: i.authMethods, + ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}), +}); + +export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) => + handlers + .handle("list", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const integrations = yield* executor.integrations.list(); + return integrations.map(toResponse); + }), + ), + ) + .handle("get", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const integration = yield* executor.integrations.get(path.slug); + if (integration === null) { + return yield* new IntegrationNotFoundError({ slug: path.slug }); + } + return toResponse(integration); + }), + ), + ) + .handle("update", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.integrations.update(path.slug, { + description: payload.description, + }); + const integration = yield* executor.integrations.get(path.slug); + if (integration === null) { + return yield* new IntegrationNotFoundError({ slug: path.slug }); + } + return toResponse(integration); + }), + ), + ) + .handle("remove", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.integrations.remove(path.slug); + return { removed: true }; + }), + ), + ) + .handle("detect", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const results = yield* executor.integrations.detect(payload.url.trim()); + return results.map((r) => ({ + kind: r.kind, + confidence: r.confidence, + endpoint: r.endpoint, + name: r.name, + slug: r.slug, + })); + }), + ), + ), +); diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 6013512c4..fd3dae2aa 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -1,12 +1,14 @@ // --------------------------------------------------------------------------- -// Shared OAuth HTTP handlers — thin forwarders over `executor.oauth.*`. -// Replaces the per-plugin copies that each had their own start / complete / -// callback handler. +// OAuth HTTP handlers — thin forwarders over `executor.oauth.*` (v2). +// +// `createClient` / `cancel` / `probe` are implemented in the SDK; +// `start` / `complete` are STUBBED there (milestone 2) and fail at runtime — +// the handlers are wired to call them so the surface is complete. // --------------------------------------------------------------------------- import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerResponse } from "effect/unstable/http"; -import { Effect, Option, Predicate, Schema } from "effect"; +import { Effect, Option, Schema } from "effect"; import { runOAuthCallback, type PopupErrorMessage } from "../oauth-popup"; import { @@ -15,10 +17,9 @@ import { OAuthProbeError, OAuthSessionNotFoundError, OAuthStartError, - resolveSecretBackedMap, - type Executor, - type OAuthStrategy, - type SecretBackedValue, + OAuthState, + type Connection, + type ConnectResult, } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; @@ -27,29 +28,34 @@ import { ExecutorService } from "../services"; const OAUTH_POPUP_CHANNEL = OAUTH_POPUP_MESSAGE_TYPE; -const resolveOAuthSecretBackedMap = ( - executor: Executor, - values: Record | undefined, - makeError: (message: string) => E, -) => - resolveSecretBackedMap({ - values, - getSecret: executor.secrets.get, - onMissing: (name) => makeError(`Secret not found for "${name}"`), - onError: (_error, name) => makeError(`Secret not found for "${name}"`), - }).pipe( - Effect.mapError((error) => - Predicate.isTagged(error, "OAuthProbeError") || Predicate.isTagged(error, "OAuthStartError") - ? (error as E) - : makeError("Secret resolution failed"), - ), - ); - const decodeOAuthStartError = Schema.decodeUnknownOption(OAuthStartError); const decodeOAuthCompleteError = Schema.decodeUnknownOption(OAuthCompleteError); const decodeOAuthProbeError = Schema.decodeUnknownOption(OAuthProbeError); const decodeOAuthSessionNotFoundError = Schema.decodeUnknownOption(OAuthSessionNotFoundError); +const connectionToResponse = (c: Connection) => ({ + owner: c.owner, + name: c.name, + integration: c.integration, + template: c.template, + provider: c.provider, + address: c.address, + identityLabel: c.identityLabel ?? null, + expiresAt: c.expiresAt ?? null, + oauthClient: c.oauthClient ?? null, + oauthClientOwner: c.oauthClientOwner ?? null, + oauthScope: c.oauthScope ?? null, +}); + +const startResultToResponse = (result: ConnectResult) => + result.status === "connected" + ? { status: "connected" as const, connection: connectionToResponse(result.connection) } + : { + status: "redirect" as const, + authorizationUrl: result.authorizationUrl, + state: result.state, + }; + const toPopupErrorMessage = (error: unknown): PopupErrorMessage => { const completeError = decodeOAuthCompleteError(error); if (Option.isSome(completeError)) @@ -76,120 +82,134 @@ const toPopupErrorMessage = (error: unknown): PopupErrorMessage => { if (Option.isSome(sessionNotFound)) return { short: "OAuth session expired or not found", - details: `Session id: ${sessionNotFound.value.sessionId}`, + details: `State: ${sessionNotFound.value.state}`, }; return { short: "Authentication failed" }; }; -const requireMatchingTokenScope = ( - routeScope: string, - tokenScope: string, -): Effect.Effect => - routeScope === tokenScope - ? Effect.void - : Effect.fail( - new OAuthStartError({ - message: "OAuth token scope must match route scope", - }), - ); - export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handlers) => handlers - .handle("probe", ({ payload }) => + .handle("createClient", ({ payload }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - const headers = yield* resolveOAuthSecretBackedMap( - executor, - payload.headers, - (message) => new OAuthProbeError({ message }), - ); - const queryParams = yield* resolveOAuthSecretBackedMap( - executor, - payload.queryParams, - (message) => new OAuthProbeError({ message }), - ); - return yield* executor.oauth.probe({ - endpoint: payload.endpoint, - headers, - queryParams, + const client = yield* executor.oauth.createClient({ + owner: payload.owner, + slug: payload.slug, + authorizationUrl: payload.authorizationUrl, + tokenUrl: payload.tokenUrl, + grant: payload.grant, + clientId: payload.clientId, + clientSecret: payload.clientSecret, + resource: payload.resource ?? null, }); + return { client }; }), ), ) - .handle("start", ({ params: path, payload }) => + .handle("registerDynamic", ({ payload }) => capture( Effect.gen(function* () { - yield* requireMatchingTokenScope(path.scopeId, payload.tokenScope); const executor = yield* ExecutorService; - const headers = yield* resolveOAuthSecretBackedMap( - executor, - payload.headers, - (message) => new OAuthStartError({ message }), - ); - const queryParams = yield* resolveOAuthSecretBackedMap( - executor, - payload.queryParams, - (message) => new OAuthStartError({ message }), - ); - return yield* executor.oauth.start({ - endpoint: payload.endpoint, - headers, - queryParams, - redirectUrl: payload.redirectUrl, - connectionId: payload.connectionId, - tokenScope: payload.tokenScope, - strategy: payload.strategy as OAuthStrategy, - pluginId: payload.pluginId, + const client = yield* executor.oauth.registerDynamicClient({ + owner: payload.owner, + slug: payload.slug, + registrationEndpoint: payload.registrationEndpoint, + authorizationUrl: payload.authorizationUrl, + tokenUrl: payload.tokenUrl, + resource: payload.resource ?? null, + scopes: payload.scopes, + tokenEndpointAuthMethodsSupported: payload.tokenEndpointAuthMethodsSupported, + clientName: payload.clientName, + redirectUri: payload.redirectUri, + originIntegration: payload.originIntegration ?? null, + }); + return { client }; + }), + ), + ) + .handle("listClients", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.oauth.listClients(); + }), + ), + ) + .handle("removeClient", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + yield* executor.oauth.removeClient(payload.owner, path.slug); + return { removed: true }; + }), + ), + ) + .handle("start", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const result = yield* executor.oauth.start({ + client: payload.client, + clientOwner: payload.clientOwner, + owner: payload.owner, + name: payload.name, + integration: payload.integration, + template: payload.template, identityLabel: payload.identityLabel, + redirectUri: payload.redirectUri, }); + return startResultToResponse(result); }), ), ) - .handle("complete", ({ params: path, payload }) => + .handle("complete", ({ payload }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - return yield* executor.oauth.complete({ + const connection = yield* executor.oauth.complete({ state: payload.state, - tokenScope: path.scopeId, code: payload.code, - error: payload.error, }); + return connectionToResponse(connection); }), ), ) - .handle("cancel", ({ params: path, payload }) => + .handle("cancel", ({ payload }) => capture( Effect.gen(function* () { - if (path.scopeId !== payload.tokenScope) { - return yield* new OAuthSessionNotFoundError({ - sessionId: payload.sessionId, - }); - } const executor = yield* ExecutorService; - yield* executor.oauth.cancel(payload.sessionId, payload.tokenScope); + yield* executor.oauth.cancel(payload.state); return { cancelled: true }; }), ), ) + .handle("probe", ({ payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.oauth.probe({ url: payload.url }); + }), + ), + ) .handle("callback", ({ query: urlParams }) => - // The callback always renders HTML, even on failure — the popup - // shows the error + messages it back to the opener. + // The callback always renders HTML, even on failure — the popup shows the + // error + messages it back to the opener. capture( Effect.gen(function* () { const executor = yield* ExecutorService; const html = yield* runOAuthCallback({ - complete: ({ state, code, error }) => + complete: ({ state, code }) => executor.oauth .complete({ - state, - code: code ?? undefined, - error: error ?? undefined, + // `runOAuthCallback`'s `state` is a raw string from the URL; + // the SDK speaks the branded `OAuthState` (nominal brand). + state: OAuthState.make(state), + code: code ?? "", }) .pipe( - Effect.tapError((cause) => + Effect.tapError((cause: unknown) => Effect.logError("OAuth callback completion failed", cause), ), ), diff --git a/packages/core/api/src/handlers/policies.ts b/packages/core/api/src/handlers/policies.ts index 40d16c7b0..828b3f95c 100644 --- a/packages/core/api/src/handlers/policies.ts +++ b/packages/core/api/src/handlers/policies.ts @@ -1,6 +1,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { Effect } from "effect"; -import type { ToolPolicy } from "@executor-js/sdk"; +import { PolicyId, type ToolPolicy } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; import { ExecutorService } from "../services"; @@ -8,7 +8,7 @@ import { capture } from "@executor-js/api"; const policyToResponse = (p: ToolPolicy) => ({ id: p.id, - scopeId: p.scopeId, + owner: p.owner, pattern: p.pattern, action: p.action, position: p.position, @@ -32,7 +32,7 @@ export const PoliciesHandlers = HttpApiBuilder.group(ExecutorApi, "policies", (h Effect.gen(function* () { const executor = yield* ExecutorService; const created = yield* executor.policies.create({ - targetScope: payload.targetScope, + owner: payload.owner, pattern: payload.pattern, action: payload.action, position: payload.position, @@ -46,8 +46,8 @@ export const PoliciesHandlers = HttpApiBuilder.group(ExecutorApi, "policies", (h Effect.gen(function* () { const executor = yield* ExecutorService; const updated = yield* executor.policies.update({ - id: path.policyId, - targetScope: payload.targetScope, + id: PolicyId.make(path.policyId), + owner: payload.owner, pattern: payload.pattern, action: payload.action, position: payload.position, @@ -56,11 +56,14 @@ export const PoliciesHandlers = HttpApiBuilder.group(ExecutorApi, "policies", (h }), ), ) - .handle("remove", ({ params: path }) => + .handle("remove", ({ params: path, payload }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - yield* executor.policies.remove({ id: path.policyId, targetScope: path.scopeId }); + yield* executor.policies.remove({ + id: PolicyId.make(path.policyId), + owner: payload.owner, + }); return { removed: true }; }), ), diff --git a/packages/core/api/src/handlers/providers.ts b/packages/core/api/src/handlers/providers.ts new file mode 100644 index 000000000..615d5d6cc --- /dev/null +++ b/packages/core/api/src/handlers/providers.ts @@ -0,0 +1,28 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; + +import { capture } from "@executor-js/api"; + +import { ExecutorApi } from "../api"; +import { ExecutorService } from "../services"; + +export const ProvidersHandlers = HttpApiBuilder.group(ExecutorApi, "providers", (handlers) => + handlers + .handle("list", () => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + return yield* executor.providers.list(); + }), + ), + ) + .handle("items", ({ params: path }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const entries = yield* executor.providers.items(path.key); + return entries.map((entry) => ({ id: entry.id, name: entry.name })); + }), + ), + ), +); diff --git a/packages/core/api/src/handlers/scope.ts b/packages/core/api/src/handlers/scope.ts deleted file mode 100644 index fab1194f8..000000000 --- a/packages/core/api/src/handlers/scope.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { Effect } from "effect"; - -import { ExecutorApi } from "../api"; -import { ExecutorService } from "../services"; -import { capture } from "@executor-js/api"; - -export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) => - handlers.handle("info", () => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - // `id` / `name` / `dir` continue to point at the outermost scope so - // existing clients keep their source writes org/workspace-scoped. - // `stack` exposes the full innermost-first scope stack so the UI can - // deliberately target per-user secret writes when binding credentials. - const scope = executor.scopes.at(-1)!; - return { - id: scope.id, - name: scope.name, - dir: scope.name, - stack: executor.scopes.map((entry) => ({ - id: entry.id, - name: entry.name, - dir: entry.name, - })), - }; - }), - ), - ), -); diff --git a/packages/core/api/src/handlers/secrets.ts b/packages/core/api/src/handlers/secrets.ts deleted file mode 100644 index 75ffa0dcb..000000000 --- a/packages/core/api/src/handlers/secrets.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { Effect } from "effect"; -import { RemoveSecretInput, SetSecretInput, type SecretRef } from "@executor-js/sdk"; - -import { ExecutorApi } from "../api"; -import { ExecutorService } from "../services"; -import { capture } from "@executor-js/api"; - -const refToResponse = (ref: SecretRef) => ({ - id: ref.id, - scopeId: ref.scopeId, - name: ref.name, - provider: ref.provider, - createdAt: ref.createdAt.getTime(), -}); - -export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) => - handlers - .handle("list", () => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - const refs = yield* executor.secrets.list(); - return refs.map(refToResponse); - }), - ), - ) - .handle("listAll", () => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - const refs = yield* executor.secrets.listAll(); - return refs.map(refToResponse); - }), - ), - ) - .handle("status", ({ params: path }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - const status = yield* executor.secrets.status(path.secretId); - return { secretId: path.secretId, status }; - }), - ), - ) - .handle("set", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - const ref = yield* executor.secrets.set( - SetSecretInput.make({ - id: payload.id, - scope: path.scopeId, - name: payload.name, - value: payload.value, - provider: payload.provider, - }), - ); - return refToResponse(ref); - }), - ), - ) - .handle("remove", ({ params: path }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - yield* executor.secrets.remove( - RemoveSecretInput.make({ - id: path.secretId, - targetScope: path.scopeId, - }), - ); - return { removed: true }; - }), - ), - ) - .handle("usages", ({ params: path }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - return yield* executor.secrets.usages(path.secretId); - }), - ), - ), -); diff --git a/packages/core/api/src/handlers/sources.ts b/packages/core/api/src/handlers/sources.ts deleted file mode 100644 index e2f781da8..000000000 --- a/packages/core/api/src/handlers/sources.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { Effect } from "effect"; -import { ScopeId, ToolId } from "@executor-js/sdk"; - -import { ExecutorApi } from "../api"; -import { ExecutorService } from "../services"; -import { capture } from "@executor-js/api"; - -export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) => - handlers - .handle("list", () => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - const sources = yield* executor.sources.list(); - return sources.map((s) => ({ - id: s.id, - scopeId: s.scopeId ? ScopeId.make(s.scopeId) : undefined, - name: s.name, - kind: s.kind, - url: s.url, - runtime: s.runtime, - canRemove: s.canRemove, - canRefresh: s.canRefresh, - canEdit: s.canEdit, - connectionIds: s.connectionIds, - })); - }), - ), - ) - .handle("remove", ({ params: path }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - yield* executor.sources.remove({ id: path.sourceId, targetScope: path.scopeId }); - return { removed: true }; - }), - ), - ) - .handle("refresh", ({ params: path }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - yield* executor.sources.refresh({ id: path.sourceId, targetScope: path.scopeId }); - return { refreshed: true }; - }), - ), - ) - .handle("tools", ({ params: path }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - // Source detail is a management view — include policy-blocked - // tools so users can see and unblock them from the same place - // they review the source's other tools. Annotations are loaded - // so the UI can show the plugin's default approval state for - // tools that have no user policy override. - const tools = yield* executor.tools.list({ - sourceId: path.sourceId, - includeAnnotations: true, - includeBlocked: true, - }); - return tools.map((t) => ({ - id: ToolId.make(t.id), - pluginId: t.pluginId, - sourceId: t.sourceId, - name: t.name, - description: t.description, - mayElicit: t.annotations?.mayElicit, - requiresApproval: t.annotations?.requiresApproval, - approvalDescription: t.annotations?.approvalDescription, - })); - }), - ), - ) - .handle("detect", ({ payload }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - const results = yield* executor.sources.detect(payload.url.trim()); - return results.map((r) => ({ - kind: r.kind, - confidence: r.confidence, - endpoint: r.endpoint, - name: r.name, - namespace: r.namespace, - })); - }), - ), - ) - .handle("configure", ({ params: path, payload }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - return yield* executor.sources.configure({ - source: payload.source, - scope: payload.scope ?? path.scopeId, - type: payload.type, - config: payload.config, - }); - }), - ), - ) - .handle("listBindings", ({ params: path }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - return yield* executor.sources.listBindings({ - source: { - id: path.sourceId, - scope: path.sourceScopeId, - }, - }); - }), - ), - ) - .handle("setBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - return yield* executor.sources.setBinding(payload); - }), - ), - ) - .handle("removeBinding", ({ payload }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - yield* executor.sources.removeBinding(payload); - return { removed: true }; - }), - ), - ) - .handle("replaceBindings", ({ payload }) => - capture( - Effect.gen(function* () { - const executor = yield* ExecutorService; - return yield* executor.sources.replaceBindings(payload); - }), - ), - ), -); diff --git a/packages/core/api/src/handlers/tools.ts b/packages/core/api/src/handlers/tools.ts index f4b7540b9..8e5818d7c 100644 --- a/packages/core/api/src/handlers/tools.ts +++ b/packages/core/api/src/handlers/tools.ts @@ -1,43 +1,50 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { Effect } from "effect"; -import { ToolId, ToolNotFoundError } from "@executor-js/sdk"; +import { ToolNotFoundError, type Tool } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; import { ExecutorService } from "../services"; import { capture } from "@executor-js/api"; +const toMetadata = (t: Tool) => ({ + address: t.address, + owner: t.owner, + integration: t.integration, + connection: t.connection, + name: t.name, + pluginId: t.pluginId, + description: t.description, + mayElicit: t.annotations?.mayElicit, + requiresApproval: t.annotations?.requiresApproval, + approvalDescription: t.annotations?.approvalDescription, + static: t.static, +}); + export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) => handlers - .handle("list", () => + .handle("list", ({ query }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - // Keep the all-tools view bounded to metadata already available - // from discovery. Per-source detail loads annotations for the - // smaller source-local management view. const tools = yield* executor.tools.list({ - includeAnnotations: false, - includeBlocked: true, + integration: query.integration, + owner: query.owner, + connection: query.connection, + query: query.query, + includeAnnotations: query.includeAnnotations === "true", + includeBlocked: query.includeBlocked !== "false", }); - return tools.map((t) => ({ - id: ToolId.make(t.id), - pluginId: t.pluginId, - sourceId: t.sourceId, - name: t.name, - description: t.description, - mayElicit: t.annotations?.mayElicit, - requiresApproval: t.annotations?.requiresApproval, - })); + return tools.map(toMetadata); }), ), ) - .handle("schema", ({ params: path }) => + .handle("schema", ({ query }) => capture( Effect.gen(function* () { const executor = yield* ExecutorService; - const schema = yield* executor.tools.schema(path.toolId); + const schema = yield* executor.tools.schema(query.address); if (schema === null) { - return yield* new ToolNotFoundError({ toolId: path.toolId }); + return yield* new ToolNotFoundError({ address: query.address }); } return schema; }), diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index 6403d8d12..2d190ddff 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -7,11 +7,10 @@ export { type PluginExtensionServices, } from "./plugin-routes"; export { ToolsApi } from "./tools/api"; -export { SourcesApi } from "./sources/api"; -export { SecretsApi } from "./secrets/api"; +export { IntegrationsApi } from "./integrations/api"; export { ConnectionsApi } from "./connections/api"; +export { ProvidersApi } from "./providers/api"; export { ExecutionsApi } from "./executions/api"; -export { ScopeApi } from "./scope/api"; export { OAuthApi } from "./oauth/api"; export { OAUTH_POPUP_MESSAGE_TYPE, diff --git a/packages/core/api/src/integrations/api.ts b/packages/core/api/src/integrations/api.ts new file mode 100644 index 000000000..98fc0a6e6 --- /dev/null +++ b/packages/core/api/src/integrations/api.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// Integrations HTTP API — the v2 catalog surface (was `sources`). +// +// An integration is the tenant-shared catalog identity (slug + description + +// which plugin owns it). The executor is bound to its `{ tenant, subject }` from +// the request auth, so integration routes carry no scope segment — the catalog +// is tenant-level. Connections (owner-scoped credentials) live in their own +// group; credential-binding endpoints are gone (folded into connections). +// --------------------------------------------------------------------------- + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { + IntegrationDetectionResult, + IntegrationNotFoundError, + IntegrationRemovalNotAllowedError, + IntegrationSlug, + InternalError, +} from "@executor-js/sdk/shared"; + +// --------------------------------------------------------------------------- +// Params +// --------------------------------------------------------------------------- + +const IntegrationParams = { slug: IntegrationSlug }; + +// --------------------------------------------------------------------------- +// Response / payload schemas +// --------------------------------------------------------------------------- + +/** Where a credential value is carried — mirrors the SDK's + * `AuthPlacementDescriptor`. */ +const PlacementDescriptor = Schema.Struct({ + carrier: Schema.Literals(["header", "query"]), + name: Schema.String, + prefix: Schema.String, +}); + +/** OAuth specifics — mirrors the SDK's `AuthMethodOAuthDescriptor`. */ +const OAuthDescriptor = Schema.Struct({ + discoveryUrl: Schema.optional(Schema.String), + authorizationUrl: Schema.optional(Schema.String), + tokenUrl: Schema.optional(Schema.String), + scopes: Schema.optional(Schema.Array(Schema.String)), + registrationEndpoint: Schema.optional(Schema.String), + supportsDynamicRegistration: Schema.optional(Schema.Boolean), +}); + +/** A single declared auth method — mirrors the SDK's `AuthMethodDescriptor`. */ +const AuthMethodDescriptorSchema = Schema.Struct({ + id: Schema.String, + label: Schema.String, + kind: Schema.Literals(["oauth", "apikey", "header", "none"]), + template: Schema.String, + placements: Schema.optional(Schema.Array(PlacementDescriptor)), + oauth: Schema.optional(OAuthDescriptor), +}); + +/** Public projection of an integration — mirrors the SDK's `Integration`. */ +const IntegrationResponse = Schema.Struct({ + slug: IntegrationSlug, + description: Schema.String, + /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ + kind: Schema.String, + canRemove: Schema.Boolean, + canRefresh: Schema.Boolean, + /** Declared auth methods derived from the owning plugin's stored config. + * Always present (possibly empty) so the client never handles absence. */ + authMethods: Schema.Array(AuthMethodDescriptorSchema), + /** Non-secret URL derived from opaque integration config for favicons. */ + displayUrl: Schema.optional(Schema.String), +}); + +const UpdateIntegrationPayload = Schema.Struct({ + description: Schema.optional(Schema.String), +}); + +const DetectRequest = Schema.Struct({ + url: Schema.String.check(Schema.isMaxLength(2_048)), +}); + +// --------------------------------------------------------------------------- +// Error schemas with HTTP status annotations +// --------------------------------------------------------------------------- + +const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); +const IntegrationRemovalNotAllowed = IntegrationRemovalNotAllowedError.annotate({ + httpApiStatus: 409, +}); + +// --------------------------------------------------------------------------- +// Group +// --------------------------------------------------------------------------- + +export const IntegrationsApi = HttpApiGroup.make("integrations") + .add( + HttpApiEndpoint.get("list", "/integrations", { + success: Schema.Array(IntegrationResponse), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.get("get", "/integrations/:slug", { + params: IntegrationParams, + success: IntegrationResponse, + error: [InternalError, IntegrationNotFound], + }), + ) + .add( + HttpApiEndpoint.patch("update", "/integrations/:slug", { + params: IntegrationParams, + payload: UpdateIntegrationPayload, + success: IntegrationResponse, + error: [InternalError, IntegrationNotFound], + }), + ) + .add( + HttpApiEndpoint.delete("remove", "/integrations/:slug", { + params: IntegrationParams, + success: Schema.Struct({ removed: Schema.Boolean }), + error: [InternalError, IntegrationRemovalNotAllowed], + }), + ) + .add( + HttpApiEndpoint.post("detect", "/integrations/detect", { + payload: DetectRequest, + success: Schema.Array(IntegrationDetectionResult), + error: InternalError, + }), + ); diff --git a/packages/core/api/src/integrations/integrations-auth-methods.test.ts b/packages/core/api/src/integrations/integrations-auth-methods.test.ts new file mode 100644 index 000000000..6f1551bf6 --- /dev/null +++ b/packages/core/api/src/integrations/integrations-auth-methods.test.ts @@ -0,0 +1,203 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { describe, expect, it } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; + +import { + IntegrationSlug, + createExecutor, + definePlugin, + type AuthMethodDescriptor, + type Executor, + type IntegrationRecord, +} from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; + +import { ExecutorApi } from "../api"; +import { observabilityMiddleware } from "../observability"; +import { CoreHandlers, ExecutionEngineService, ExecutorService } from "../server"; + +// --------------------------------------------------------------------------- +// The catalog response surfaces each plugin's DECLARED auth methods (projected +// from the integration's stored config via `describeAuthMethods`). This proves +// the visible bug is fixed: an OAuth integration with ZERO connections still +// advertises an `oauth` method, and an apikey/header integration advertises an +// `apikey` method — the client no longer has to infer from connections. +// +// We register integrations through a lightweight inline plugin so the test +// stays in `@executor-js/api` without a cross-package dependency on the MCP +// plugin; the MCP-specific projection is covered by the MCP plugin's own +// `describe-auth-methods` test. +// --------------------------------------------------------------------------- + +const OAUTH_METHOD: AuthMethodDescriptor = { + id: "oauth2", + label: "OAuth", + kind: "oauth", + template: "oauth2", + oauth: { discoveryUrl: "https://x.example/oauth/mcp", supportsDynamicRegistration: true }, +}; + +const APIKEY_METHOD: AuthMethodDescriptor = { + id: "header", + label: "API key (header)", + kind: "apikey", + template: "header", + placements: [{ carrier: "header", name: "Authorization", prefix: "" }], +}; + +// A plugin that projects its stored `config.methods` blob straight back as the +// declared auth methods, exercising the catalog wiring end-to-end. +const declaringPlugin = definePlugin(() => ({ + id: "declaring" as const, + storage: () => ({}), + describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { + const config = record.config as { + readonly methods?: readonly AuthMethodDescriptor[]; + readonly displayUrl?: string; + }; + return config?.methods ?? []; + }, + describeIntegrationDisplay: (record: IntegrationRecord) => { + const config = record.config as { + readonly methods?: readonly AuthMethodDescriptor[]; + readonly displayUrl?: string; + }; + return { url: config?.displayUrl }; + }, + extension: (ctx) => ({ + seed: (slug: IntegrationSlug, methods: readonly AuthMethodDescriptor[], displayUrl?: string) => + ctx.core.integrations.register({ + slug, + description: String(slug), + config: { methods, displayUrl }, + }), + }), +}))(); + +const webHandlerFor = (executor: Executor) => + Effect.acquireRelease( + Effect.sync(() => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(ExecutorApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(observabilityMiddleware(ExecutorApi)), + Layer.provide(Layer.succeed(ExecutorService)(executor)), + Layer.provide( + Layer.succeed(ExecutionEngineService)({} as ExecutionEngineService["Service"]), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ), + { disableLogger: true }, + ), + ), + (web) => Effect.promise(() => web.dispose()), + ); + +const handlerContextFor = (executor: Executor) => + Context.make(ExecutorService, executor).pipe( + Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), + ); + +interface IntegrationResponseBody { + readonly slug: string; + readonly authMethods: readonly AuthMethodDescriptor[]; + readonly displayUrl?: string; +} + +describe("catalog surfaces declared auth methods", () => { + it.effect("an OAuth integration with zero connections advertises an oauth method", () => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + const slug = IntegrationSlug.make("oauth-server"); + yield* executor.declaring.seed(slug, [OAUTH_METHOD]); + + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + + const response = yield* Effect.promise(() => + web.handler( + new Request(`http://localhost/integrations/${encodeURIComponent(String(slug))}`), + context, + ), + ); + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => response.json())) as IntegrationResponseBody; + + expect(body.authMethods).toEqual([OAUTH_METHOD]); + expect(body.authMethods[0]?.kind).toBe("oauth"); + expect(body.authMethods[0]?.oauth?.supportsDynamicRegistration).toBe(true); + }), + ); + + it.effect("an apikey integration advertises an apikey method", () => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + const slug = IntegrationSlug.make("apikey-server"); + yield* executor.declaring.seed(slug, [APIKEY_METHOD]); + + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + + const response = yield* Effect.promise(() => + web.handler( + new Request(`http://localhost/integrations/${encodeURIComponent(String(slug))}`), + context, + ), + ); + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => response.json())) as IntegrationResponseBody; + + expect(body.authMethods).toEqual([APIKEY_METHOD]); + expect(body.authMethods[0]?.kind).toBe("apikey"); + }), + ); + + it.effect("list surfaces authMethods and a plugin with no projector yields []", () => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + yield* executor.declaring.seed(IntegrationSlug.make("oauth-server"), [OAUTH_METHOD]); + yield* executor.declaring.seed(IntegrationSlug.make("bare-server"), []); + + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + + const response = yield* Effect.promise(() => + web.handler(new Request("http://localhost/integrations"), context), + ); + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => + response.json(), + )) as readonly IntegrationResponseBody[]; + + const oauth = body.find((i) => i.slug === "oauth-server"); + const bare = body.find((i) => i.slug === "bare-server"); + expect(oauth?.authMethods).toEqual([OAUTH_METHOD]); + expect(bare?.authMethods).toEqual([]); + }), + ); + + it.effect("surfaces plugin-derived display URLs without exposing config", () => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: [declaringPlugin] })); + const slug = IntegrationSlug.make("autumn"); + yield* executor.declaring.seed(slug, [], "https://api.useautumn.com"); + + const web = yield* webHandlerFor(executor); + const context = handlerContextFor(executor); + + const response = yield* Effect.promise(() => + web.handler( + new Request(`http://localhost/integrations/${encodeURIComponent(String(slug))}`), + context, + ), + ); + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => response.json())) as IntegrationResponseBody; + + expect(body.displayUrl).toBe("https://api.useautumn.com"); + expect("config" in body).toBe(false); + }), + ); +}); diff --git a/packages/core/api/src/oauth-popup.ts b/packages/core/api/src/oauth-popup.ts index f3fa94a5e..86f933fa7 100644 --- a/packages/core/api/src/oauth-popup.ts +++ b/packages/core/api/src/oauth-popup.ts @@ -106,7 +106,16 @@ export const popupDocument = ( ${detailsHtml} `; }; diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index a7af770b1..ec89f91aa 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -1,110 +1,190 @@ // --------------------------------------------------------------------------- -// Shared OAuth HTTP API — one endpoint set per flow, served at -// `/scopes/:scopeId/oauth/{probe,start,complete,callback}` for every -// plugin that needs OAuth. `pluginId` lives on the request body so the -// completion callback can route to the right plugin at persist time. -// Replaces the per-plugin copies that lived under -// `/scopes/:scopeId/{mcp,openapi,graphql}/oauth/*`. +// OAuth HTTP API — the v2 OAuth surface. +// +// OAuth is a credential mechanism, not an integration type. A `createClient` +// registers an owner-scoped app (its own endpoints + client id/secret); `start` +// runs that client's flow to mint a Connection for one integration; `complete` +// exchanges the authorization code; `cancel` drops an in-flight session; +// `probe` discovers an authorization-server's metadata for the onboarding UI. +// +// NOTE(v2): `start`/`complete` are STUBBED in the SDK (milestone 2) — the routes +// are wired to call them but will fail at runtime until the flow is implemented. // --------------------------------------------------------------------------- import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { + AuthTemplateSlug, + ConnectionAddress, + ConnectionName, + IntegrationSlug, InternalError, + OAuthClientSlug, OAuthCompleteError, OAuthProbeError, + OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, - OAuthStrategySchema, - ScopeId, - SecretBackedMap, + OAuthState, + Owner, + ProviderKey, } from "@executor-js/sdk/shared"; -const ScopeParams = { scopeId: ScopeId }; // --------------------------------------------------------------------------- -// Probe — decide between dynamic-DCR and paste-your-credentials flows +// Shared connection projection (start "connected" / complete results). // --------------------------------------------------------------------------- -const ProbePayload = Schema.Struct({ - endpoint: Schema.String, - headers: Schema.optional(SecretBackedMap), - queryParams: Schema.optional(SecretBackedMap), +const ConnectionResponse = Schema.Struct({ + owner: Owner, + name: ConnectionName, + integration: IntegrationSlug, + template: AuthTemplateSlug, + provider: ProviderKey, + address: ConnectionAddress, + identityLabel: Schema.NullOr(Schema.String), + expiresAt: Schema.NullOr(Schema.Number), + // The OAuth app (`oauth_client` slug) that minted this connection — these + // results always come from an OAuth flow, so it is non-null in practice. Just + // a slug, never a secret; kept consistent with the connections-list shape. + oauthClient: Schema.NullOr(OAuthClientSlug), + oauthClientOwner: Schema.NullOr(Owner), + oauthScope: Schema.NullOr(Schema.String), }); -const ProbeResponse = Schema.Struct({ - resourceMetadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)), - resourceMetadataUrl: Schema.NullOr(Schema.String), - authorizationServerMetadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)), - authorizationServerMetadataUrl: Schema.NullOr(Schema.String), - authorizationServerUrl: Schema.NullOr(Schema.String), - supportsDynamicRegistration: Schema.Boolean, - isBearerChallengeEndpoint: Schema.Boolean, +// --------------------------------------------------------------------------- +// createClient — register an owner-scoped OAuth app. +// --------------------------------------------------------------------------- + +const CreateClientPayload = Schema.Struct({ + owner: Owner, + slug: OAuthClientSlug, + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + grant: Schema.Literals(["authorization_code", "client_credentials"]), + clientId: Schema.String, + clientSecret: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const CreateClientResponse = Schema.Struct({ + client: OAuthClientSlug, }); // --------------------------------------------------------------------------- -// Start — persists an `oauth2_session` row; for user-interactive flows -// returns an authorization URL, for `client-credentials` mints the -// Connection inline and returns it under `completedConnection`. +// registerDynamic — RFC 7591 Dynamic Client Registration. The server mints the +// client id (and possibly a client secret); the user pastes NOTHING. The payload +// deliberately carries NO clientId/clientSecret, and the response is the slug +// only — the minted secret is never returned over the wire. // --------------------------------------------------------------------------- -const StartPayload = Schema.Struct({ - /** Resource URL — used by probe/display, not by the start flow for - * static strategies. */ - endpoint: Schema.String, - headers: Schema.optional(SecretBackedMap), - queryParams: Schema.optional(SecretBackedMap), - /** Where the authorization server will bounce the user's browser - * back to. Pass a placeholder (e.g. the token URL) for flows that - * don't redirect; the service still persists it. */ - redirectUrl: Schema.String, - /** Stable id the Connection the exchange will mint. Caller typically - * derives this as `${pluginId}-oauth2-${namespace}` so the source - * row can be stamped atomically with the flow start. */ - connectionId: Schema.String, - /** Scope where the resulting Connection + its backing secrets land. */ - tokenScope: Schema.String, - strategy: OAuthStrategySchema, - /** Which plugin is initiating the flow. Persisted on the session + - * stamped on the minted Connection's identity-label prefix. */ - pluginId: Schema.String, - /** Human label for the minted Connection. */ - identityLabel: Schema.optional(Schema.String), +const RegisterDynamicPayload = Schema.Struct({ + owner: Owner, + slug: OAuthClientSlug, + registrationEndpoint: Schema.String, + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), + scopes: Schema.Array(Schema.String), + tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)), + clientName: Schema.optional(Schema.String), + redirectUri: Schema.optional(Schema.NullOr(Schema.String)), + originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)), }); -const StartResponse = Schema.Struct({ - sessionId: Schema.String, - /** Present for user-interactive strategies. `null` for - * `client-credentials` (no redirect). */ - authorizationUrl: Schema.NullOr(Schema.String), - /** Filled for strategies that mint the Connection inline. */ - completedConnection: Schema.NullOr(Schema.Struct({ connectionId: Schema.String })), +const RegisterDynamicResponse = Schema.Struct({ + client: OAuthClientSlug, }); // --------------------------------------------------------------------------- -// Complete — exchange the code, mint the Connection, drop the session. +// listClients — metadata-only summaries of the clients visible to the caller +// (their org's shared clients + their own user clients). The `clientSecret` is +// NEVER part of this projection. // --------------------------------------------------------------------------- -const CompletePayload = Schema.Struct({ - state: Schema.String, - code: Schema.optional(Schema.String), - error: Schema.optional(Schema.String), +const OAuthClientSummaryResponse = Schema.Struct({ + owner: Owner, + slug: OAuthClientSlug, + grant: Schema.Literals(["authorization_code", "client_credentials"]), + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), + clientId: Schema.String, + origin: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("manual") }), + Schema.Struct({ + kind: Schema.Literal("dynamic_client_registration"), + integration: Schema.optional(Schema.NullOr(IntegrationSlug)), + }), + ]), }); -const CompleteResponse = Schema.Struct({ - connectionId: Schema.String, - expiresAt: Schema.NullOr(Schema.Number), - scope: Schema.NullOr(Schema.String), +const ListClientsResponse = Schema.Array(OAuthClientSummaryResponse); + +// --------------------------------------------------------------------------- +// removeClient — permanently delete an owner-scoped OAuth app. The app is keyed +// by (owner, slug) — the slug alone is not globally unique — so the slug is a +// path param and the owner is in the payload (mirrors the policies/connections +// delete shape). Idempotent: removing an already-gone app still returns +// `{ removed: true }`. Connections that referenced the slug are NOT cascaded; +// they keep their stored value and fail at the next token refresh. +// --------------------------------------------------------------------------- + +const RemoveClientParams = { slug: OAuthClientSlug }; + +const RemoveClientPayload = Schema.Struct({ + owner: Owner, +}); + +const RemoveClientResponse = Schema.Struct({ + removed: Schema.Boolean, +}); + +// --------------------------------------------------------------------------- +// start — run a client's flow to mint a connection for one integration. The +// status discriminates "connected" (inline, e.g. client_credentials) from +// "redirect" (user must visit the authorization URL). +// --------------------------------------------------------------------------- + +const StartPayload = Schema.Struct({ + client: OAuthClientSlug, + /** The owner of `client` (a Personal connection may use a shared Workspace app). */ + clientOwner: Owner, + owner: Owner, + name: ConnectionName, + integration: IntegrationSlug, + template: AuthTemplateSlug, + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), + redirectUri: Schema.optional(Schema.NullOr(Schema.String)), +}); + +const StartResponse = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("connected"), + connection: ConnectionResponse, + }), + Schema.Struct({ + status: Schema.Literal("redirect"), + authorizationUrl: Schema.String, + state: OAuthState, + }), +]); + +// --------------------------------------------------------------------------- +// complete — exchange the authorization code, mint the connection. +// --------------------------------------------------------------------------- + +const CompletePayload = Schema.Struct({ + state: OAuthState, + code: Schema.String, }); // --------------------------------------------------------------------------- -// Cancel — drop an in-flight session without exchanging. +// cancel — drop an in-flight session without exchanging. // --------------------------------------------------------------------------- const CancelPayload = Schema.Struct({ - sessionId: Schema.String, - /** Scope that owns the pending OAuth session. Must match start.tokenScope. */ - tokenScope: Schema.String, + state: OAuthState, }); const CancelResponse = Schema.Struct({ @@ -112,9 +192,26 @@ const CancelResponse = Schema.Struct({ }); // --------------------------------------------------------------------------- -// OAuth callback — GET with `state` + `code` (or `error`) query params. -// Renders the popup HTML directly; the popup script posts the completion -// result back to the opener via `postMessage` / `BroadcastChannel`. +// probe — discover an authorization-server's metadata. +// --------------------------------------------------------------------------- + +const ProbePayload = Schema.Struct({ + url: Schema.String, +}); + +const ProbeResponse = Schema.Struct({ + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), + scopesSupported: Schema.optional(Schema.Array(Schema.String)), + registrationEndpoint: Schema.optional(Schema.NullOr(Schema.String)), + tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)), +}); + +// --------------------------------------------------------------------------- +// callback — GET with `state` + `code` (or `error`) query params. Renders the +// popup HTML directly; the popup script posts the completion result back to the +// opener via `postMessage` / `BroadcastChannel`. // --------------------------------------------------------------------------- const CallbackUrlParams = Schema.Struct({ @@ -126,47 +223,81 @@ const CallbackUrlParams = Schema.Struct({ const HtmlResponse = Schema.String.pipe(HttpApiSchema.asText()); +// --------------------------------------------------------------------------- +// Error schemas with HTTP status annotations +// --------------------------------------------------------------------------- + +const OAuthStart = OAuthStartError.annotate({ httpApiStatus: 400 }); +const OAuthComplete = OAuthCompleteError.annotate({ httpApiStatus: 400 }); +const OAuthProbe = OAuthProbeError.annotate({ httpApiStatus: 400 }); +const OAuthRegisterDynamic = OAuthRegisterDynamicError.annotate({ httpApiStatus: 400 }); +const OAuthSessionNotFound = OAuthSessionNotFoundError.annotate({ httpApiStatus: 404 }); + // --------------------------------------------------------------------------- // Group // --------------------------------------------------------------------------- export const OAuthApi = HttpApiGroup.make("oauth") .add( - HttpApiEndpoint.post("probe", "/scopes/:scopeId/oauth/probe", { - params: ScopeParams, - payload: ProbePayload, - success: ProbeResponse, - error: [InternalError, OAuthProbeError], + HttpApiEndpoint.post("createClient", "/oauth/clients", { + payload: CreateClientPayload, + success: CreateClientResponse, + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("registerDynamic", "/oauth/clients/register-dynamic", { + payload: RegisterDynamicPayload, + success: RegisterDynamicResponse, + error: [InternalError, OAuthRegisterDynamic], + }), + ) + .add( + HttpApiEndpoint.get("listClients", "/oauth/clients", { + success: ListClientsResponse, + error: InternalError, }), ) .add( - HttpApiEndpoint.post("start", "/scopes/:scopeId/oauth/start", { - params: ScopeParams, + HttpApiEndpoint.delete("removeClient", "/oauth/clients/:slug", { + params: RemoveClientParams, + payload: RemoveClientPayload, + success: RemoveClientResponse, + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("start", "/oauth/start", { payload: StartPayload, success: StartResponse, - error: [InternalError, OAuthStartError], + error: [InternalError, OAuthStart], }), ) .add( - HttpApiEndpoint.post("complete", "/scopes/:scopeId/oauth/complete", { - params: ScopeParams, + HttpApiEndpoint.post("complete", "/oauth/complete", { payload: CompletePayload, - success: CompleteResponse, - error: [InternalError, OAuthCompleteError, OAuthSessionNotFoundError], + success: ConnectionResponse, + error: [InternalError, OAuthComplete, OAuthSessionNotFound], }), ) .add( - HttpApiEndpoint.post("cancel", "/scopes/:scopeId/oauth/cancel", { - params: ScopeParams, + HttpApiEndpoint.post("cancel", "/oauth/cancel", { payload: CancelPayload, success: CancelResponse, - error: [InternalError, OAuthSessionNotFoundError], + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.post("probe", "/oauth/probe", { + payload: ProbePayload, + success: ProbeResponse, + error: [InternalError, OAuthProbe], }), ) .add( HttpApiEndpoint.get("callback", "/oauth/callback", { query: CallbackUrlParams, success: HtmlResponse, - error: [InternalError, OAuthCompleteError, OAuthSessionNotFoundError], + error: [InternalError, OAuthComplete, OAuthSessionNotFound], }), ); diff --git a/packages/core/api/src/policies/api.ts b/packages/core/api/src/policies/api.ts index 21fdad29e..a3b9f76de 100644 --- a/packages/core/api/src/policies/api.ts +++ b/packages/core/api/src/policies/api.ts @@ -1,13 +1,20 @@ +// --------------------------------------------------------------------------- +// Policies HTTP API — owner-scoped tool policies (v2). +// +// Policies gate tool invocation by pattern + action, scoped to an owner +// (org | user) instead of a scope id. Org rules are the outer guardrail; the +// most restrictive matched action across owners wins. +// --------------------------------------------------------------------------- + import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; -import { InternalError, PolicyId, ScopeId, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; +import { InternalError, Owner, PolicyId, ToolPolicyActionSchema } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Params // --------------------------------------------------------------------------- -const ScopeParams = { scopeId: ScopeId }; -const PolicyParams = { scopeId: ScopeId, policyId: PolicyId }; +const PolicyParams = { policyId: PolicyId }; // --------------------------------------------------------------------------- // Response / payload schemas @@ -15,7 +22,7 @@ const PolicyParams = { scopeId: ScopeId, policyId: PolicyId }; const ToolPolicyResponse = Schema.Struct({ id: PolicyId, - scopeId: ScopeId, + owner: Owner, pattern: Schema.String, action: ToolPolicyActionSchema, position: Schema.String, @@ -24,41 +31,43 @@ const ToolPolicyResponse = Schema.Struct({ }); const CreateToolPolicyPayload = Schema.Struct({ - targetScope: ScopeId, + owner: Owner, pattern: Schema.String, action: ToolPolicyActionSchema, position: Schema.optional(Schema.String), }); const UpdateToolPolicyPayload = Schema.Struct({ - targetScope: ScopeId, + owner: Owner, pattern: Schema.optional(Schema.String), action: Schema.optional(ToolPolicyActionSchema), position: Schema.optional(Schema.String), }); +const RemoveToolPolicyPayload = Schema.Struct({ + owner: Owner, +}); + // --------------------------------------------------------------------------- // Group // --------------------------------------------------------------------------- export const PoliciesApi = HttpApiGroup.make("policies") .add( - HttpApiEndpoint.get("list", "/scopes/:scopeId/policies", { - params: ScopeParams, + HttpApiEndpoint.get("list", "/policies", { success: Schema.Array(ToolPolicyResponse), error: InternalError, }), ) .add( - HttpApiEndpoint.post("create", "/scopes/:scopeId/policies", { - params: ScopeParams, + HttpApiEndpoint.post("create", "/policies", { payload: CreateToolPolicyPayload, success: ToolPolicyResponse, error: InternalError, }), ) .add( - HttpApiEndpoint.patch("update", "/scopes/:scopeId/policies/:policyId", { + HttpApiEndpoint.patch("update", "/policies/:policyId", { params: PolicyParams, payload: UpdateToolPolicyPayload, success: ToolPolicyResponse, @@ -66,8 +75,9 @@ export const PoliciesApi = HttpApiGroup.make("policies") }), ) .add( - HttpApiEndpoint.delete("remove", "/scopes/:scopeId/policies/:policyId", { + HttpApiEndpoint.delete("remove", "/policies/:policyId", { params: PolicyParams, + payload: RemoveToolPolicyPayload, success: Schema.Struct({ removed: Schema.Boolean }), error: InternalError, }), diff --git a/packages/core/api/src/providers/api.ts b/packages/core/api/src/providers/api.ts new file mode 100644 index 000000000..72b209db6 --- /dev/null +++ b/packages/core/api/src/providers/api.ts @@ -0,0 +1,48 @@ +// --------------------------------------------------------------------------- +// Providers HTTP API — the v2 credential-backend discovery surface. +// +// A `CredentialProvider` is where a connection's value actually lives (the +// default store for pasted values, or an external backend like 1Password / +// keychain / workos-vault). `list` enumerates registered provider keys; `items` +// browses a provider's entries so the UI can pick an opaque `ProviderItemId` to +// reference when creating a `{ from: { provider, id } }` connection. +// --------------------------------------------------------------------------- + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +import { InternalError, ProviderItemId, ProviderKey } from "@executor-js/sdk/shared"; + +// --------------------------------------------------------------------------- +// Params +// --------------------------------------------------------------------------- + +const ProviderParams = { key: ProviderKey }; + +// --------------------------------------------------------------------------- +// Response schemas — mirrors the SDK's `ProviderEntry`. +// --------------------------------------------------------------------------- + +const ProviderEntryResponse = Schema.Struct({ + id: ProviderItemId, + name: Schema.String, +}); + +// --------------------------------------------------------------------------- +// Group +// --------------------------------------------------------------------------- + +export const ProvidersApi = HttpApiGroup.make("providers") + .add( + HttpApiEndpoint.get("list", "/providers", { + success: Schema.Array(ProviderKey), + error: InternalError, + }), + ) + .add( + HttpApiEndpoint.get("items", "/providers/:key/items", { + params: ProviderParams, + success: Schema.Array(ProviderEntryResponse), + error: InternalError, + }), + ); diff --git a/packages/core/api/src/scope/api.ts b/packages/core/api/src/scope/api.ts deleted file mode 100644 index e7560a9e9..000000000 --- a/packages/core/api/src/scope/api.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { Schema } from "effect"; -import { InternalError, ScopeId } from "@executor-js/sdk/shared"; - -// --------------------------------------------------------------------------- -// Response schemas -// --------------------------------------------------------------------------- - -const ScopeInfoResponse = Schema.Struct({ - id: ScopeId, - name: Schema.String, - dir: Schema.String, - stack: Schema.Array( - Schema.Struct({ - id: ScopeId, - name: Schema.String, - dir: Schema.String, - }), - ), -}); - -// --------------------------------------------------------------------------- -// Group -// --------------------------------------------------------------------------- - -export const ScopeApi = HttpApiGroup.make("scope").add( - HttpApiEndpoint.get("info", "/scope", { - success: ScopeInfoResponse, - error: InternalError, - }), -); diff --git a/packages/core/api/src/scoped-targets.test.ts b/packages/core/api/src/scoped-targets.test.ts index 638495c35..5c0967929 100644 --- a/packages/core/api/src/scoped-targets.test.ts +++ b/packages/core/api/src/scoped-targets.test.ts @@ -4,24 +4,40 @@ import { describe, expect, it } from "@effect/vitest"; import { Context, Effect, Layer } from "effect"; import { - ConnectionId, - CreateConnectionInput, - Scope, - ScopeId, - SecretId, - SetSecretInput, - TokenMaterial, + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ToolName, createExecutor, definePlugin, type Executor, } from "@executor-js/sdk"; -import { makeTestConfig } from "@executor-js/sdk/testing"; -import { memorySecretsPlugin } from "@executor-js/sdk/testing"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; import { ExecutorApi } from "./api"; import { observabilityMiddleware } from "./observability"; import { CoreHandlers, ExecutionEngineService, ExecutorService } from "./server"; +// --------------------------------------------------------------------------- +// v2 owner-scoped API behaviour. +// +// v1's "explicit target scope" tests gated writes by a route scope vs a payload +// `targetScope`, and exercised the `[user, org]` scope-stack shadowing rules. +// v2 has neither: the executor binds `{ tenant, subject }` from auth, addresses +// name their owner explicitly (`tools....`), and there is +// no shadowing (D12) — an org connection and a user connection are DISTINCT rows +// with distinct addresses. These ports keep the spirit (writes target an owner, +// owner rows are independent) against the real v2 surface. +// --------------------------------------------------------------------------- + +// removed: "policy update uses the row target scope instead of the route read +// scope" — v2 policies have no route read-scope vs payload target-scope split; +// they are owner-scoped. The owner-scoped create/update path is covered below. +// removed: "OAuth start requires the route scope to match the requested token +// scope" and "OAuth complete requires the route scope to match the pending +// session scope" — v2 OAuth carries no scope segment; start/complete are stubbed +// in the SDK (milestone 2) and have no scope-matching gate to assert. + const webHandlerFor = (executor: Executor) => Effect.acquireRelease( Effect.sync(() => @@ -47,37 +63,42 @@ const handlerContextFor = (executor: Executor) => Context.add(ExecutionEngineService, {} as ExecutionEngineService["Service"]), ); -const scope = (id: ScopeId, name: string) => Scope.make({ id, name, createdAt: new Date() }); - -const toScopeRows = (rows: unknown): ReadonlyArray<{ readonly scope_id: string }> => - rows as ReadonlyArray<{ readonly scope_id: string }>; +const INTEGRATION = IntegrationSlug.make("vercel"); -const connectionProviderPlugin = definePlugin(() => ({ - id: "test-connection-provider" as const, +// A plugin that owns the `vercel` integration and produces one tool per +// connection so the per-connection address scheme is exercised. +const vercelPlugin = definePlugin(() => ({ + id: "vercel" as const, storage: () => ({}), - connectionProviders: [{ key: "memory-connection" }], -})); + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("deploy"), description: "deploy" }], + }), + invokeTool: () => Effect.succeed({ ok: true }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEGRATION, + description: "Vercel", + config: {}, + }), + }), +}))(); -describe("core API explicit target scopes", () => { - it.effect("policy update uses the row target scope instead of the route read scope", () => +describe("core API owner-scoped writes (v2)", () => { + it.effect("policy create + update target an explicit owner", () => Effect.gen(function* () { - const userScope = ScopeId.make("api-user"); - const orgScope = ScopeId.make("api-org"); - const executor = yield* createExecutor( - makeTestConfig({ - scopes: [scope(userScope, "user"), scope(orgScope, "org")], - }), - ); + const executor = yield* createExecutor(makeTestConfig({})); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); const createResponse = yield* Effect.promise(() => web.handler( - new Request(`http://localhost/scopes/${userScope}/policies`, { + new Request("http://localhost/policies", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - targetScope: orgScope, + owner: "org", pattern: "vercel.*", action: "require_approval", }), @@ -86,21 +107,17 @@ describe("core API explicit target scopes", () => { ), ); expect(createResponse.status).toBe(200); - const created = (yield* Effect.promise(() => createResponse.json())) as { id: string }; + const created = (yield* Effect.promise(() => createResponse.json())) as { + id: string; + }; const updateResponse = yield* Effect.promise(() => web.handler( - new Request( - `http://localhost/scopes/${userScope}/policies/${encodeURIComponent(created.id)}`, - { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - targetScope: orgScope, - action: "block", - }), - }, - ), + new Request(`http://localhost/policies/${encodeURIComponent(created.id)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ owner: "org", action: "block" }), + }), context, ), ); @@ -109,63 +126,41 @@ describe("core API explicit target scopes", () => { const policies = yield* executor.policies.list(); expect(policies[0]).toMatchObject({ id: created.id, - scopeId: orgScope, + owner: "org", action: "block", }); }), ); - it.effect("connection remove deletes the route target scope row, not the innermost row", () => + it.effect("connection remove deletes the named owner row, not the other owner", () => Effect.gen(function* () { - const userScope = ScopeId.make("api-user"); - const orgScope = ScopeId.make("api-org"); const config = makeTestConfig({ - scopes: [scope(userScope, "user"), scope(orgScope, "org")], - plugins: [memorySecretsPlugin(), connectionProviderPlugin()] as const, + plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); + yield* executor.vercel.seed(); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); - const connectionId = ConnectionId.make("shared-connection"); - yield* executor.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: orgScope, - provider: "memory-connection", - identityLabel: "Org connection", - accessToken: TokenMaterial.make({ - secretId: SecretId.make("org-shared-connection.access_token"), - name: "Org access token", - value: "org-token", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - yield* executor.connections.create( - CreateConnectionInput.make({ - id: connectionId, - scope: userScope, - provider: "memory-connection", - identityLabel: "User connection", - accessToken: TokenMaterial.make({ - secretId: SecretId.make("user-shared-connection.access_token"), - name: "User access token", - value: "user-token", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); + const name = ConnectionName.make("default"); + yield* executor.connections.create({ + owner: "org", + name, + integration: INTEGRATION, + template: AuthTemplateSlug.make("apiKey"), + value: "org-token", + }); + yield* executor.connections.create({ + owner: "user", + name, + integration: INTEGRATION, + template: AuthTemplateSlug.make("apiKey"), + value: "user-token", + }); const response = yield* Effect.promise(() => web.handler( - new Request(`http://localhost/scopes/${orgScope}/connections/${connectionId}`, { + new Request(`http://localhost/connections/org/${INTEGRATION}/${name}`, { method: "DELETE", }), context, @@ -173,122 +168,92 @@ describe("core API explicit target scopes", () => { ); expect(response.status).toBe(200); - const rows = toScopeRows( - yield* Effect.promise(() => - config.db.findMany("connection", { - where: (b) => b("id", "=", connectionId), - }), - ), - ); - expect(rows.map((row) => row.scope_id).sort()).toEqual([String(userScope)]); + // The org row is gone; the user row survives (no shadowing — distinct rows). + const remaining = yield* executor.connections.list({ + integration: INTEGRATION, + }); + expect(remaining.map((c) => c.owner).sort()).toEqual(["user"]); }), ); - it.effect("OAuth start requires the route scope to match the requested token scope", () => + it.effect("connection create accepts pasted values payloads", () => Effect.gen(function* () { - const userScope = ScopeId.make("api-user"); - const orgScope = ScopeId.make("api-org"); const config = makeTestConfig({ - scopes: [scope(userScope, "user"), scope(orgScope, "org")], - plugins: [memorySecretsPlugin(), connectionProviderPlugin()] as const, + plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, }); const executor = yield* createExecutor(config); + yield* executor.vercel.seed(); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); const response = yield* Effect.promise(() => web.handler( - new Request(`http://localhost/scopes/${userScope}/oauth/start`, { + new Request("http://localhost/connections", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - endpoint: "https://api.example.com", - redirectUrl: "https://app.example.com/oauth/callback", - connectionId: "example-oauth", - tokenScope: orgScope, - pluginId: "test-connection-provider", - strategy: { - kind: "authorization-code", - authorizationEndpoint: "https://auth.example.com/oauth/authorize", - tokenEndpoint: "https://auth.example.com/oauth/token", - clientIdSecretId: "client-id", - clientSecretSecretId: null, - scopes: [], - }, + owner: "user", + name: "api-key", + integration: "vercel", + template: "apiKey", + values: { token: "user-token" }, }), }), context, ), ); - expect(response.status).toBe(400); - const sessions = yield* Effect.promise(() => config.db.findMany("oauth2_session")); - expect(sessions).toEqual([]); + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => response.json())) as { + readonly owner: string; + readonly name: string; + readonly provider: string; + }; + expect(body).toMatchObject({ + owner: "user", + name: "apiKey", + provider: "memory", + }); }), ); - it.effect("OAuth complete requires the route scope to match the pending session scope", () => + it.effect("connection list returns both owners' rows under one integration", () => Effect.gen(function* () { - const userScope = ScopeId.make("api-user"); - const orgScope = ScopeId.make("api-org"); - const executor = yield* createExecutor( - makeTestConfig({ - scopes: [scope(userScope, "user"), scope(orgScope, "org")], - plugins: [memorySecretsPlugin(), connectionProviderPlugin()] as const, - }), - ); + const config = makeTestConfig({ + plugins: [memoryCredentialsPlugin(), vercelPlugin] as const, + }); + const executor = yield* createExecutor(config); + yield* executor.vercel.seed(); const web = yield* webHandlerFor(executor); const context = handlerContextFor(executor); - yield* executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make("client-id"), - scope: userScope, - name: "Client ID", - value: "client-id-value", - }), - ); - const started = yield* executor.oauth.start({ - endpoint: "https://api.example.com", - redirectUrl: "https://app.example.com/oauth/callback", - connectionId: "example-oauth", - tokenScope: String(userScope), - pluginId: "test-connection-provider", - strategy: { - kind: "authorization-code", - authorizationEndpoint: "https://auth.example.com/oauth/authorize", - tokenEndpoint: "https://auth.example.com/oauth/token", - clientIdSecretId: "client-id", - clientSecretSecretId: null, - scopes: [], - }, + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("default"), + integration: INTEGRATION, + template: AuthTemplateSlug.make("apiKey"), + value: "org-token", + }); + yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: INTEGRATION, + template: AuthTemplateSlug.make("apiKey"), + value: "user-token", }); const response = yield* Effect.promise(() => - web.handler( - new Request(`http://localhost/scopes/${orgScope}/oauth/complete`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ state: started.sessionId, code: "code" }), - }), - context, - ), + web.handler(new Request("http://localhost/connections", { method: "GET" }), context), ); - - expect(response.status).toBe(404); - const row = yield* executor.oauth - .complete({ - state: started.sessionId, - tokenScope: String(orgScope), - error: "cancelled", - }) - .pipe( - Effect.match({ - onFailure: (error) => error, - onSuccess: () => null, - }), - ); - expect(row).toMatchObject({ _tag: "OAuthSessionNotFoundError" }); + expect(response.status).toBe(200); + const body = (yield* Effect.promise(() => response.json())) as ReadonlyArray<{ + readonly owner: string; + readonly name: string; + }>; + expect(body.map((c) => `${c.owner}:${c.name}`).sort()).toEqual([ + "org:default", + "user:personal", + ]); }), ); }); diff --git a/packages/core/api/src/secrets/api.ts b/packages/core/api/src/secrets/api.ts deleted file mode 100644 index a204ab32e..000000000 --- a/packages/core/api/src/secrets/api.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { Schema } from "effect"; -import { - InternalError, - ScopeId, - SecretId, - SecretInUseError, - SecretNotFoundError, - SecretOwnedByConnectionError, - SecretResolutionError, - Usage, -} from "@executor-js/sdk/shared"; - -// --------------------------------------------------------------------------- -// Params -// --------------------------------------------------------------------------- - -const ScopeParams = { scopeId: ScopeId }; -const SecretParams = { scopeId: ScopeId, secretId: SecretId }; - -// --------------------------------------------------------------------------- -// Response / payload schemas -// --------------------------------------------------------------------------- - -const SecretRefResponse = Schema.Struct({ - id: SecretId, - scopeId: ScopeId, - name: Schema.String, - provider: Schema.String, - createdAt: Schema.Number, -}); - -const SecretStatusResponse = Schema.Struct({ - secretId: SecretId, - status: Schema.Literals(["resolved", "missing"]), -}); - -const SetSecretPayload = Schema.Struct({ - id: SecretId, - name: Schema.String, - value: Schema.String, - provider: Schema.optional(Schema.String), -}); - -// --------------------------------------------------------------------------- -// Error schemas with HTTP status annotations -// --------------------------------------------------------------------------- - -const SecretNotFound = SecretNotFoundError.annotate({ httpApiStatus: 404 }); -const SecretResolution = SecretResolutionError.annotate({ httpApiStatus: 500 }); -const SecretOwnedByConnection = SecretOwnedByConnectionError.annotate({ httpApiStatus: 409 }); -const SecretInUse = SecretInUseError.annotate({ httpApiStatus: 409 }); - -// --------------------------------------------------------------------------- -// Group -// --------------------------------------------------------------------------- - -export const SecretsApi = HttpApiGroup.make("secrets") - .add( - HttpApiEndpoint.get("list", "/scopes/:scopeId/secrets", { - params: ScopeParams, - success: Schema.Array(SecretRefResponse), - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.get("listAll", "/scopes/:scopeId/secrets/all", { - params: ScopeParams, - success: Schema.Array(SecretRefResponse), - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.get("status", "/scopes/:scopeId/secrets/:secretId/status", { - params: SecretParams, - success: SecretStatusResponse, - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.post("set", "/scopes/:scopeId/secrets", { - params: ScopeParams, - payload: SetSecretPayload, - success: SecretRefResponse, - error: [InternalError, SecretResolution], - }), - ) - .add( - HttpApiEndpoint.delete("remove", "/scopes/:scopeId/secrets/:secretId", { - params: SecretParams, - success: Schema.Struct({ removed: Schema.Boolean }), - error: [InternalError, SecretNotFound, SecretOwnedByConnection, SecretInUse], - }), - ) - .add( - HttpApiEndpoint.get("usages", "/scopes/:scopeId/secrets/:secretId/usages", { - params: SecretParams, - success: Schema.Array(Usage), - error: InternalError, - }), - ); diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 52d5f9b49..8d492763e 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -2,9 +2,11 @@ export { ExecutorService, ExecutionEngineService } from "./services"; export { CoreHandlers, ToolsHandlers, - SourcesHandlers, - SecretsHandlers, - ScopeHandlers, + IntegrationsHandlers, + ConnectionsHandlers, + ProvidersHandlers, + OAuthHandlers, + PoliciesHandlers, ExecutionsHandlers, } from "./handlers"; export { diff --git a/packages/core/api/src/server/execution-stack-middleware.ts b/packages/core/api/src/server/execution-stack-middleware.ts index 2c86ce7c6..d9abe82f1 100644 --- a/packages/core/api/src/server/execution-stack-middleware.ts +++ b/packages/core/api/src/server/execution-stack-middleware.ts @@ -175,7 +175,9 @@ export const makeExecutionStackMiddleware = < resolved.organizationName, ).pipe( Effect.provide(options.stackLayer), - Effect.provideService(RequestWebOrigin, { origin: new URL(webRequest.url).origin }), + Effect.provideService(RequestWebOrigin, { + origin: requestWebOriginFromRequest(webRequest), + }), ); return yield* httpEffect.pipe( Effect.provideService(AuthContext, auth), @@ -197,3 +199,36 @@ export const makeExecutionStackMiddleware = < const isPrincipal = ( value: Principal | HttpServerResponse.HttpServerResponse, ): value is Principal => !HttpServerResponse.isHttpServerResponse(value); + +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +const parseOrigin = (value: string): URL | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: new URL() throws on malformed origin; no Effect equivalent for this sync parse + try { + const parsed = new URL(value); + parsed.pathname = "/"; + parsed.search = ""; + parsed.hash = ""; + return parsed; + } catch { + return null; + } +}; + +const isLoopbackOrigin = (value: URL): boolean => LOOPBACK_HOSTNAMES.has(value.hostname); + +const originString = (value: URL): string => value.origin; + +export const requestWebOriginFromRequest = (request: Request): string => { + const requestUrl = new URL(request.url); + const requestOrigin = requestUrl.origin; + const browserOriginHeader = request.headers.get("origin"); + if (!browserOriginHeader) return requestOrigin; + + const browserOrigin = parseOrigin(browserOriginHeader); + if (!browserOrigin) return requestOrigin; + if (!isLoopbackOrigin(requestUrl) || !isLoopbackOrigin(browserOrigin)) return requestOrigin; + if (requestUrl.protocol !== browserOrigin.protocol) return requestOrigin; + if (requestUrl.port !== browserOrigin.port) return requestOrigin; + return originString(browserOrigin); +}; diff --git a/packages/core/api/src/server/fixed-execution-middleware.ts b/packages/core/api/src/server/fixed-execution-middleware.ts index a284c2d3e..f8c92e6a1 100644 --- a/packages/core/api/src/server/fixed-execution-middleware.ts +++ b/packages/core/api/src/server/fixed-execution-middleware.ts @@ -4,17 +4,17 @@ // // The per-request `ExecutionStackMiddleware` resolves a `Principal` and then // builds a FRESH per-(user, org) executor each request via `makeExecutionStack` -// -> `makeScopedExecutor` -> `makeUserOrgScopeStack(accountId, organizationId, -// organizationName)`. That is the cloud / self-host model: a 2-level -// `[user-org:…, org]` scope stack derived from identity. +// -> `makeScopedExecutor`, binding `{ tenant: organizationId, subject: +// accountId }`. That is the cloud / self-host model: a per-request executor +// derived from identity. // // Local is structurally different: ONE executor is built once at boot over a -// SINGLE scope derived from the working directory (`-`), with +// SINGLE tenant derived from the working directory, with // `oauthEndpointUrlPolicy: { allowHttp: true }`, and shared across every request -// (and the in-process MCP). There is no (user, org) and no per-request scope. -// Forcing local through the scope-stack middleware would (a) swap its cwd scope -// for a synthetic `user-org:` scope key — orphaning existing `~/.executor` data -// — and (b) silently drop `allowHttp`. +// (and the in-process MCP). There is no per-request (user, org) binding. Forcing +// local through the scoped middleware would (a) swap its cwd tenant for a +// synthetic identity-derived one — orphaning existing `~/.executor` data — and +// (b) silently drop `allowHttp`. // // So a host whose execution is a single boot executor supplies a // `FixedExecutionProvider` (the pre-built executor + engine) and this middleware diff --git a/packages/core/api/src/server/oauth-origin.test.ts b/packages/core/api/src/server/oauth-origin.test.ts new file mode 100644 index 000000000..29ccc563f --- /dev/null +++ b/packages/core/api/src/server/oauth-origin.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { requestWebOriginFromRequest } from "./execution-stack-middleware"; +import { resolveScopedWebBaseUrl } from "./scoped-executor"; + +describe("OAuth web origin resolution", () => { + it("uses the browser loopback origin when a local proxy rewrites the request host", () => { + const request = new Request("https://127.0.0.1:5394/api/oauth/start", { + method: "POST", + headers: { Origin: "https://localhost:5394" }, + }); + + expect(requestWebOriginFromRequest(request)).toBe("https://localhost:5394"); + }); + + it("does not trust a non-loopback Origin header for redirect origin derivation", () => { + const request = new Request("https://127.0.0.1:5394/api/oauth/start", { + method: "POST", + headers: { Origin: "https://evil.example" }, + }); + + expect(requestWebOriginFromRequest(request)).toBe("https://127.0.0.1:5394"); + }); + + it("prefers a loopback request origin over a configured base URL for local browser OAuth", () => { + expect( + resolveScopedWebBaseUrl({ + configuredWebBaseUrl: "https://executor.sh", + requestOrigin: "https://localhost:5394", + }), + ).toBe("https://localhost:5394"); + }); + + it("keeps the configured base URL for non-local requests", () => { + expect( + resolveScopedWebBaseUrl({ + configuredWebBaseUrl: "https://executor.sh", + requestOrigin: "https://preview.example", + }), + ).toBe("https://executor.sh"); + }); +}); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 5440a5368..25fd5558d 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -22,15 +22,21 @@ // // This is host-composition machinery: it lives in `@executor-js/api/server` // (the host surface), not in `@executor-js/sdk` (the plugin-author contract). -// `createExecutor`/`Executor` and the `makeUserOrgScopeStack` scope-id contract -// stay in the SDK and are imported from there. +// `createExecutor`/`Executor` and the branded `Tenant`/`Subject` ids stay in the +// SDK and are imported from there. +// +// v2: the executor binds to `{ tenant, subject }` instead of a scope stack. The +// org id is the tenant (the isolation partition that owns the catalog); the +// account id is the acting subject (drives `owner: "user"` rows). The old +// `makeUserOrgScopeStack([userOrgScope, orgScope])` is gone. // --------------------------------------------------------------------------- import { Context, Effect, Option } from "effect"; import { createExecutor, - makeUserOrgScopeStack, + Subject, + Tenant, type AnyPlugin, type Executor, type StorageFailure, @@ -52,13 +58,34 @@ export interface HostConfigShape { readonly allowLocalNetwork: boolean; /** * Base URL of the executor's web UI. Threaded into `coreTools.webBaseUrl` so - * `secrets.create` can point the user at `${webBaseUrl}/secrets?...`. + * `connections.createHandoff` can point the user at + * `${webBaseUrl}/integrations/{slug}?addAccount=1`. * * Optional: when a host can't know its public URL at boot (a Worker has no * static URL var), leave it unset and `makeScopedExecutor` falls back to the * current request's origin (`RequestWebOrigin`). An explicit value always wins. */ readonly webBaseUrl?: string; + /** + * Public path of THIS host's OAuth callback route — the host's API + * `mountPrefix` joined with the global `/oauth/callback` route + * (packages/core/api/src/oauth/api.ts). The redirect URI sent to providers is + * `${webBaseUrl}${oauthCallbackPath}`. + * + * Defaults to `/oauth/callback` (correct for a host that serves the typed API + * at root, e.g. local). A host that mounts the API under a prefix MUST set this + * to `${mountPrefix}/oauth/callback` (cloud: `/api/oauth/callback`) — otherwise + * the redirect URI omits the prefix, so it 404s on return and never matches + * what the provider has registered. + */ + readonly oauthCallbackPath?: string; + /** + * Whether Executor's built-in agent tools should expose credential provider + * discovery. Local/self-host can use this for 1Password/keychain style + * provider browsing; cloud hides it because WorkOS Vault is an implementation + * detail of credential storage. + */ + readonly exposeCredentialProviders?: boolean; } export class HostConfig extends Context.Service()( @@ -83,6 +110,26 @@ export class RequestWebOrigin extends Context.Service { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: new URL() throws on malformed origin; no Effect equivalent for this sync parse + try { + const parsed = new URL(origin); + return LOOPBACK_HOSTNAMES.has(parsed.hostname); + } catch { + return false; + } +}; + +export const resolveScopedWebBaseUrl = (input: { + readonly configuredWebBaseUrl?: string; + readonly requestOrigin?: string; +}): string | undefined => { + if (input.requestOrigin && isLoopbackOrigin(input.requestOrigin)) return input.requestOrigin; + return input.configuredWebBaseUrl ?? input.requestOrigin; +}; + // --------------------------------------------------------------------------- // PluginsProvider seam — the per-host (and possibly per-request) plugin array. // @@ -102,14 +149,15 @@ export class PluginsProvider extends Context.Service( accountId: string, organizationId: string, - organizationName: string, + // Kept in the signature for parity with `makeExecutionStack` / + // `EngineStackIdentity` (the engine decorator still wants it); not part of the + // v2 executor binding, which is `{ tenant, subject }` only. + _organizationName: string, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { const { db } = yield* DbProvider; @@ -135,27 +186,46 @@ export const makeScopedExecutor = < // non-request callers — `coreTools.webBaseUrl` is optional and only the // browser-handoff tools require it (they fail clearly if it's truly absent). const requestOrigin = yield* Effect.serviceOption(RequestWebOrigin); - const webBaseUrl = - config.webBaseUrl ?? - Option.match(requestOrigin, { onNone: () => undefined, onSome: (o) => o.origin }); + const webBaseUrl = resolveScopedWebBaseUrl({ + configuredWebBaseUrl: config.webBaseUrl, + requestOrigin: Option.match(requestOrigin, { + onNone: () => undefined, + onSome: (o) => o.origin, + }), + }); + + // EXPLICIT OAuth wiring: the redirect callback the host serves and sends to + // providers is `${webBaseUrl}${oauthCallbackPath}` — the host's API mount + // prefix joined with the global `/oauth/callback` route + // (packages/core/api/src/oauth/api.ts). The base is derived from the SAME + // source as `webBaseUrl` (an explicit `HostConfig.webBaseUrl`, else the + // in-flight request origin). The PATH defaults to root (`/oauth/callback`, + // correct for a root-mounted host like local); a prefix-mounted host (cloud: + // `/api`) sets `oauthCallbackPath` so the prefix is not dropped. When no base + // is known (a non-HTTP caller), `redirectUri` stays `undefined` and the OAuth + // service fails loudly on redirect flows rather than silently using localhost. + const oauthCallbackPath = config.oauthCallbackPath ?? "/oauth/callback"; + const redirectUri = webBaseUrl ? new URL(oauthCallbackPath, webBaseUrl).toString() : undefined; const plugins = pluginsFactory(); const httpClientLayer = makeHostedHttpClientLayer({ allowLocalNetwork: config.allowLocalNetwork, }); - // The account id is the first segment of the persisted `user-org:` scope key - // (its namespace name is the contract; `makeUserOrgScopeStack` keeps it). - const scopes = makeUserOrgScopeStack(accountId, organizationId, organizationName); - + // The org id is the tenant (catalog partition); the account id is the acting + // subject (drives `owner: "user"` rows). `organizationName` is no longer part + // of the executor binding — it stays on `AuthContext` for display. const executor = yield* createExecutor({ - scopes, + tenant: Tenant.make(organizationId), + subject: Subject.make(accountId), db, plugins, httpClientLayer, onElicitation: "accept-all", + redirectUri, coreTools: { webBaseUrl, + includeProviders: config.exposeCredentialProviders ?? true, }, }); // The seam erases the plugin tuple type; the caller re-narrows via the diff --git a/packages/core/api/src/sources/api.ts b/packages/core/api/src/sources/api.ts deleted file mode 100644 index 205ed6dfe..000000000 --- a/packages/core/api/src/sources/api.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { Schema } from "effect"; -import { - InternalError, - CredentialBindingRef, - RemoveSourceCredentialBindingInput, - ScopeId, - SetSourceCredentialBindingInput, - SourceRemovalNotAllowedError, - ReplaceSourceCredentialBindingsInput, - ToolId, -} from "@executor-js/sdk/shared"; - -// --------------------------------------------------------------------------- -// Params -// --------------------------------------------------------------------------- - -const ScopeParams = { scopeId: ScopeId }; -const SourceParams = { scopeId: ScopeId, sourceId: Schema.String }; -const SourceBindingParams = { - scopeId: ScopeId, - sourceId: Schema.String, - sourceScopeId: ScopeId, -}; - -// --------------------------------------------------------------------------- -// Response schemas -// --------------------------------------------------------------------------- - -const SourceResponse = Schema.Struct({ - id: Schema.String, - scopeId: Schema.optional(ScopeId), - name: Schema.String, - kind: Schema.String, - url: Schema.optional(Schema.String), - runtime: Schema.optional(Schema.Boolean), - canRemove: Schema.optional(Schema.Boolean), - canRefresh: Schema.optional(Schema.Boolean), - canEdit: Schema.optional(Schema.Boolean), - connectionIds: Schema.optional(Schema.Array(Schema.String)), -}); - -const SourceRemoveResponse = Schema.Struct({ - removed: Schema.Boolean, -}); - -const SourceRefreshResponse = Schema.Struct({ - refreshed: Schema.Boolean, -}); - -const ToolMetadataResponse = Schema.Struct({ - id: ToolId, - pluginId: Schema.String, - sourceId: Schema.String, - name: Schema.String, - description: Schema.optional(Schema.String), - mayElicit: Schema.optional(Schema.Boolean), - /** Plugin-derived default approval annotation. Surfaces in the UI as - * the "default" policy when no user `tool_policy` rule matches. */ - requiresApproval: Schema.optional(Schema.Boolean), - approvalDescription: Schema.optional(Schema.String), -}); - -const DetectRequest = Schema.Struct({ - url: Schema.String.check(Schema.isMaxLength(2_048)), -}); - -const ConfigureSourceRequest = Schema.Struct({ - source: Schema.Struct({ - id: Schema.String, - scope: ScopeId, - }), - scope: ScopeId, - type: Schema.optional(Schema.String), - config: Schema.Unknown, -}); - -const DetectResultResponse = Schema.Struct({ - kind: Schema.String, - confidence: Schema.Literals(["high", "medium", "low"]), - endpoint: Schema.String, - name: Schema.String, - namespace: Schema.String, -}); - -// --------------------------------------------------------------------------- -// Error schemas with HTTP status annotations -// --------------------------------------------------------------------------- - -const SourceRemovalNotAllowed = SourceRemovalNotAllowedError.annotate({ httpApiStatus: 409 }); - -// --------------------------------------------------------------------------- -// Group -// --------------------------------------------------------------------------- - -export const SourcesApi = HttpApiGroup.make("sources") - .add( - HttpApiEndpoint.get("list", "/scopes/:scopeId/sources", { - params: ScopeParams, - success: Schema.Array(SourceResponse), - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.delete("remove", "/scopes/:scopeId/sources/:sourceId", { - params: SourceParams, - success: SourceRemoveResponse, - error: [InternalError, SourceRemovalNotAllowed], - }), - ) - .add( - HttpApiEndpoint.post("refresh", "/scopes/:scopeId/sources/:sourceId/refresh", { - params: SourceParams, - success: SourceRefreshResponse, - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.get("tools", "/scopes/:scopeId/sources/:sourceId/tools", { - params: SourceParams, - success: Schema.Array(ToolMetadataResponse), - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.post("detect", "/scopes/:scopeId/sources/detect", { - params: ScopeParams, - payload: DetectRequest, - success: Schema.Array(DetectResultResponse), - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.post("configure", "/scopes/:scopeId/sources/configure", { - params: ScopeParams, - payload: ConfigureSourceRequest, - success: Schema.Unknown, - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.get( - "listBindings", - "/scopes/:scopeId/sources/:sourceId/base/:sourceScopeId/bindings", - { - params: SourceBindingParams, - success: Schema.Array(CredentialBindingRef), - error: InternalError, - }, - ), - ) - .add( - HttpApiEndpoint.post("setBinding", "/scopes/:scopeId/source-bindings", { - params: ScopeParams, - payload: SetSourceCredentialBindingInput, - success: CredentialBindingRef, - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.post("removeBinding", "/scopes/:scopeId/source-bindings/remove", { - params: ScopeParams, - payload: RemoveSourceCredentialBindingInput, - success: Schema.Struct({ removed: Schema.Boolean }), - error: InternalError, - }), - ) - .add( - HttpApiEndpoint.post("replaceBindings", "/scopes/:scopeId/source-bindings/replace", { - params: ScopeParams, - payload: ReplaceSourceCredentialBindingsInput, - success: Schema.Array(CredentialBindingRef), - error: InternalError, - }), - ); diff --git a/packages/core/api/src/tools/api.ts b/packages/core/api/src/tools/api.ts index 8d1ac726d..767c6609d 100644 --- a/packages/core/api/src/tools/api.ts +++ b/packages/core/api/src/tools/api.ts @@ -1,40 +1,61 @@ -import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; -import { Schema } from "effect"; -import { InternalError, ScopeId, ToolId, ToolNotFoundError } from "@executor-js/sdk/shared"; - // --------------------------------------------------------------------------- -// Params +// Tools HTTP API — the v2 catalog read surface. +// +// Tools are per-connection and address-keyed +// (`tools....`). `list` returns the +// persisted tool rows filtered by an optional `ToolListFilter`; `schema` +// returns the full schema view for one address. The branded `ToolAddress` is a +// dotted string, so it is carried as a query param, not a path segment. // --------------------------------------------------------------------------- -const PathParams = { - scopeId: ScopeId, - toolId: ToolId, -}; +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; +import { + ConnectionName, + IntegrationSlug, + InternalError, + Owner, + ToolAddress, + ToolNotFoundError, + ToolSchemaView, +} from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Response schemas // --------------------------------------------------------------------------- const ToolMetadataResponse = Schema.Struct({ - id: ToolId, - pluginId: Schema.String, - sourceId: Schema.String, + address: ToolAddress, + owner: Owner, + integration: IntegrationSlug, + connection: ConnectionName, name: Schema.String, - description: Schema.optional(Schema.String), + pluginId: Schema.String, + description: Schema.String, mayElicit: Schema.optional(Schema.Boolean), + /** Plugin-derived default approval annotation. Surfaces in the UI as the + * "default" policy when no user `tool_policy` rule matches. */ requiresApproval: Schema.optional(Schema.Boolean), + approvalDescription: Schema.optional(Schema.String), + static: Schema.optional(Schema.Boolean), +}); + +// --------------------------------------------------------------------------- +// Query — `tools.list` filters (mirrors `ToolListFilter`). +// --------------------------------------------------------------------------- + +const ListToolsQuery = Schema.Struct({ + integration: Schema.optional(IntegrationSlug), + owner: Schema.optional(Owner), + connection: Schema.optional(ConnectionName), + query: Schema.optional(Schema.String), + // Query params arrive as strings; the handler interprets "true"/"false". + includeAnnotations: Schema.optional(Schema.String), + includeBlocked: Schema.optional(Schema.String), }); -const ToolSchemaResponse = Schema.Struct({ - id: ToolId, - name: Schema.optional(Schema.String), - description: Schema.optional(Schema.String), - inputTypeScript: Schema.optional(Schema.String), - outputTypeScript: Schema.optional(Schema.String), - typeScriptDefinitions: Schema.optional(Schema.Record(Schema.String, Schema.String)), - inputSchema: Schema.optional(Schema.Unknown), - outputSchema: Schema.optional(Schema.Unknown), - schemaDefinitions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +const SchemaQuery = Schema.Struct({ + address: ToolAddress, }); // --------------------------------------------------------------------------- @@ -49,16 +70,16 @@ const ToolNotFound = ToolNotFoundError.annotate({ httpApiStatus: 404 }); export const ToolsApi = HttpApiGroup.make("tools") .add( - HttpApiEndpoint.get("list", "/scopes/:scopeId/tools", { - params: { scopeId: PathParams.scopeId }, + HttpApiEndpoint.get("list", "/tools", { + query: ListToolsQuery, success: Schema.Array(ToolMetadataResponse), error: InternalError, }), ) .add( - HttpApiEndpoint.get("schema", "/scopes/:scopeId/tools/:toolId/schema", { - params: PathParams, - success: ToolSchemaResponse, + HttpApiEndpoint.get("schema", "/tools/schema", { + query: SchemaQuery, + success: ToolSchemaView, error: [InternalError, ToolNotFound], }), ); diff --git a/packages/core/cli/CHANGELOG.md b/packages/core/cli/CHANGELOG.md index 28f48d909..6b1a82a73 100644 --- a/packages/core/cli/CHANGELOG.md +++ b/packages/core/cli/CHANGELOG.md @@ -1,4 +1,22 @@ -# @executor-js/cli changelog +# @executor-js/cli -This file exists for Changesets release workflow compatibility. -Canonical user-facing release notes are published on GitHub Releases. +## 0.2.8 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + +## 0.2.7 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + +## 0.2.6 + +### Patch Changes + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]: + - @executor-js/sdk@1.5.0 diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index 9bf53c7c7..0d6490c4b 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cli", - "version": "0.2.5", + "version": "0.2.8", "description": "CLI for the executor SDK — schema generation, migrations", "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/cli", "bugs": { diff --git a/packages/core/cli/src/commands/schema.test.ts b/packages/core/cli/src/commands/schema.test.ts index 94e119303..221b71279 100644 --- a/packages/core/cli/src/commands/schema.test.ts +++ b/packages/core/cli/src/commands/schema.test.ts @@ -35,8 +35,8 @@ describe("schema generate", () => { const generated = await readFile(join(cwd, "generated/executor-schema.ts"), "utf8"); expect(generated).toContain("executor_cli_test"); - expect(generated).toContain("source"); - expect(generated).toContain("credential_binding"); + expect(generated).toContain("integration"); + expect(generated).toContain("connection"); }), (cwd) => Effect.promise(() => rm(cwd, { recursive: true, force: true })), ), diff --git a/packages/core/config/CHANGELOG.md b/packages/core/config/CHANGELOG.md index e9220d135..27583636f 100644 --- a/packages/core/config/CHANGELOG.md +++ b/packages/core/config/CHANGELOG.md @@ -1,4 +1,24 @@ -# @executor-js/config changelog +# @executor-js/config -This file exists for Changesets release workflow compatibility. -Canonical user-facing release notes are published on GitHub Releases. +## 1.5.2 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + +## 1.5.1 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + +## 1.5.0 + +### Patch Changes + +- [#922](https://github.com/RhysSullivan/executor/pull/922) [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Move `effect` from `dependencies` to `peerDependencies` in the published library packages so consumers provide a single shared Effect instance. + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]: + - @executor-js/sdk@1.5.0 diff --git a/packages/core/config/package.json b/packages/core/config/package.json index e2ca0cb6c..e1cf284d5 100644 --- a/packages/core/config/package.json +++ b/packages/core/config/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/config", - "version": "1.4.33", + "version": "1.5.2", "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/config", "bugs": { "url": "https://github.com/RhysSullivan/executor/issues" @@ -37,7 +37,6 @@ }, "dependencies": { "@executor-js/sdk": "workspace:*", - "effect": "catalog:", "jiti": "^2.6.1", "jsonc-parser": "^3.3.1" }, @@ -45,8 +44,12 @@ "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", "@types/node": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/packages/core/config/src/schema.ts b/packages/core/config/src/schema.ts index 15f06a839..c233106fe 100644 --- a/packages/core/config/src/schema.ts +++ b/packages/core/config/src/schema.ts @@ -57,8 +57,10 @@ export const McpAuthConfig = Schema.Union([ Schema.Struct({ kind: Schema.Literal("oauth2"), /** Stable id of the SDK Connection holding access + refresh token - * material. Scope shadowing means the same id resolves per-user - * via the executor's innermost-wins lookup. */ + * material. The connection names its owner explicitly (org or user), + * so the id resolves to exactly one connection — no scope stack, no + * per-user shadowing. Core resolves its value (refreshing oauth + * tokens) at execute time via the connection's provider. */ connectionId: Schema.String, }), ]); diff --git a/packages/core/execution/CHANGELOG.md b/packages/core/execution/CHANGELOG.md index b6e17ae43..b37b2c1cd 100644 --- a/packages/core/execution/CHANGELOG.md +++ b/packages/core/execution/CHANGELOG.md @@ -1,4 +1,27 @@ -# @executor-js/execution changelog +# @executor-js/execution -This file exists for Changesets release workflow compatibility. -Canonical user-facing release notes are published on GitHub Releases. +## 1.5.2 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.2 + - @executor-js/codemode-core@1.5.2 + +## 1.5.1 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/sdk@1.5.1 + - @executor-js/codemode-core@1.5.1 + +## 1.5.0 + +### Patch Changes + +- [#922](https://github.com/RhysSullivan/executor/pull/922) [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Move `effect` from `dependencies` to `peerDependencies` in the published library packages so consumers provide a single shared Effect instance. + +- Updated dependencies [[`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68), [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad)]: + - @executor-js/sdk@1.5.0 + - @executor-js/codemode-core@1.5.0 diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index c606d2d6f..06aa67378 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/execution", - "version": "1.4.33", + "version": "1.5.2", "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/execution", "bugs": { "url": "https://github.com/RhysSullivan/executor/issues" @@ -44,16 +44,19 @@ }, "dependencies": { "@executor-js/codemode-core": "workspace:*", - "@executor-js/sdk": "workspace:*", - "effect": "catalog:" + "@executor-js/sdk": "workspace:*" }, "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/runtime-quickjs": "workspace:*", "@types/node": "catalog:", "bun-types": "catalog:", + "effect": "catalog:", "tsup": "catalog:", "typescript": "catalog:", "vitest": "catalog:" + }, + "peerDependencies": { + "effect": "catalog:" } } diff --git a/packages/core/execution/src/description.test.ts b/packages/core/execution/src/description.test.ts index 94a39111b..2cc38c0e7 100644 --- a/packages/core/execution/src/description.test.ts +++ b/packages/core/execution/src/description.test.ts @@ -1,99 +1,124 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Schema } from "effect"; +import { Effect } from "effect"; -import { createExecutor, definePlugin } from "@executor-js/sdk"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + createExecutor, + definePlugin, + type CredentialProvider, +} from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; import { buildExecuteDescription } from "./description"; -const EmptyInputSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(Schema.Struct({})), -); +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), + ), + }; +}; -// Two plugins registering static sources whose ids are distinct from their -// pluginIds/names. If `buildExecuteDescription` ever renders the wrong field -// (e.g. pluginId, an internal UUID, or the source name), these assertions -// fail — which is the class of bug a hand-rolled fake `Executor` would miss. +const GITHUB = IntegrationSlug.make("github"); +const SLACK = IntegrationSlug.make("slack"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +// v2 port: available entries are saved connection prefixes, not integration +// slugs. Multiple saved connections can point at the same integration, and the +// callable path needs `..`. const githubPlugin = definePlugin(() => ({ id: "github-plugin" as const, + credentialProviders: [memoryProvider()], storage: () => ({}), - staticSources: () => [ - { - id: "github", - kind: "in-memory", - name: "GitHub", - tools: [ - { - name: "noop", - description: "noop", - inputSchema: EmptyInputSchema, - handler: () => Effect.succeed(null), - }, - ], - }, - ], -})); + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: GITHUB, + description: "GitHub", + config: {}, + }), + }), +}))(); const slackPlugin = definePlugin(() => ({ id: "slack-plugin" as const, storage: () => ({}), - staticSources: () => [ - { - id: "slack", - kind: "in-memory", - name: "Slack Workspace", - tools: [ - { - name: "noop", - description: "noop", - inputSchema: EmptyInputSchema, - handler: () => Effect.succeed(null), - }, - ], - }, - ], -})); + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: SLACK, + description: "Slack Workspace", + config: {}, + }), + }), +}))(); describe("buildExecuteDescription", () => { - it.effect("renders real source ids as namespaces (sorted) through the real executor flow", () => + it.effect("renders real connection prefixes separately through the real executor flow", () => Effect.gen(function* () { - // Intentionally register in non-alphabetical order — the formatter - // is expected to sort by source id. const executor = yield* createExecutor( - makeTestConfig({ plugins: [slackPlugin(), githubPlugin()] as const }), + makeTestConfig({ plugins: [slackPlugin, githubPlugin] as const }), ); + yield* executor["slack-plugin"].seed(); + yield* executor["github-plugin"].seed(); + yield* executor.connections.create({ + owner: "user", + name: ConnectionName.make("personal"), + integration: GITHUB, + template: TEMPLATE, + value: "user-token", + }); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("prod"), + integration: GITHUB, + template: TEMPLATE, + value: "org-token", + }); const description = yield* buildExecuteDescription(executor); // Stable anchor from the workflow preamble. expect(description).toContain("Execute TypeScript in a sandboxed runtime"); - // The namespaces section header. - expect(description).toContain("## Available namespaces"); - // Each source renders with its ACTUAL id, without display labels or plugin ids. - expect(description).toContain("- `github`"); - expect(description).toContain("- `slack`"); - expect(description).not.toContain("GitHub"); + expect(description).toContain("## Available connection prefixes"); + expect(description).toContain("- `github.org.prod`"); + expect(description).toContain("- `github.user.personal`"); + expect(description).not.toContain("## Available namespaces"); expect(description).not.toContain("Slack Workspace"); expect(description).not.toContain("`github-plugin`"); expect(description).not.toContain("`slack-plugin`"); + expect(description).not.toContain("- `github`"); - // Sort order: `github` before `slack`. - const githubIdx = description.indexOf("`github`"); - const slackIdx = description.indexOf("`slack`"); - expect(githubIdx).toBeGreaterThan(-1); - expect(slackIdx).toBeGreaterThan(-1); - expect(githubIdx).toBeLessThan(slackIdx); + const orgIdx = description.indexOf("`github.org.prod`"); + const userIdx = description.indexOf("`github.user.personal`"); + expect(orgIdx).toBeGreaterThan(-1); + expect(userIdx).toBeGreaterThan(-1); + expect(orgIdx).toBeLessThan(userIdx); }), ); - it.effect("omits the Available namespaces section when no plugins register sources", () => + it.effect("omits the Available connection prefixes section when no connections exist", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); const description = yield* buildExecuteDescription(executor); expect(description).toContain("Execute TypeScript in a sandboxed runtime"); - expect(description).not.toContain("## Available namespaces"); + expect(description).not.toContain("## Available connection prefixes"); }), ); }); diff --git a/packages/core/execution/src/description.ts b/packages/core/execution/src/description.ts index 4b2e5939a..25fbee059 100644 --- a/packages/core/execution/src/description.ts +++ b/packages/core/execution/src/description.ts @@ -1,54 +1,59 @@ import { Effect } from "effect"; -import type { Executor, Source } from "@executor-js/sdk/core"; +import type { Connection, Executor } from "@executor-js/sdk/core"; /** * Builds a tool description dynamically. * * Structure: * 1. Workflow (top — critical, least likely to be truncated) - * 2. Available namespaces (bottom) + * 2. Available connection prefixes (bottom) + * + * v2: callable API tools are scoped by saved connections. A tool's sandbox + * address is `tools....`, so the useful + * inventory is the connection prefix rather than only the integration slug. */ export const buildExecuteDescription = (executor: Executor): Effect.Effect => Effect.gen(function* () { - const sources: readonly Source[] = yield* executor.sources.list().pipe( + const connections: readonly Connection[] = yield* executor.connections.list().pipe( // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ExecutionEngine.getDescription currently exposes no error channel; engine typed-error widening is covered separately Effect.orDie, - Effect.withSpan("executor.sources.list"), + Effect.withSpan("executor.connections.list"), ); - const description = yield* Effect.sync(() => formatDescription(sources)).pipe( + const description = yield* Effect.sync(() => + formatDescription(connections.map((connection) => connectionPath(connection))), + ).pipe( Effect.withSpan("schema.compile.description", { - attributes: { "executor.source_count": sources.length }, + attributes: { "executor.connection_count": connections.length }, }), ); yield* Effect.annotateCurrentSpan({ - "executor.source_count": sources.length, + "executor.connection_count": connections.length, "schema.kind": "execute", - // Source/connector inventory so a failing session build (which runs this - // during init) names *what* it was resolving: empty/OpenAPI-only scopes - // build cleanly, scopes with remote MCP connectors are the ones that fail. - "executor.source_ids": sources - .map((source) => source.id) + // Connection inventory so a failing session build (which runs this during + // init) names the callable prefixes it resolved without listing tools. + "executor.connection_addresses": connections + .map((connection) => connectionPath(connection)) .slice(0, 50) .join(","), - "executor.source_kinds": [...new Set(sources.map((source) => source.kind))].join(","), - "executor.source_plugin_ids": [...new Set(sources.map((source) => source.pluginId))].join( - ",", - ), - "executor.connection_count": sources.reduce( - (total, source) => total + source.connectionIds.length, - 0, - ), - "executor.sources_with_connection": sources.filter( - (source) => source.connectionIds.length > 0, - ).length, + "executor.connection_integrations": [ + ...new Set(connections.map((connection) => String(connection.integration))), + ].join(","), + "executor.connection_owners": [ + ...new Set(connections.map((connection) => connection.owner)), + ].join(","), }); return description; }).pipe(Effect.withSpan("schema.describe.execute")); -const formatDescription = (sources: readonly Source[]): string => { +const connectionPath = (connection: Connection): string => { + const address = String(connection.address); + return address.startsWith("tools.") ? address.slice("tools.".length) : address; +}; + +const formatDescription = (connectionPrefixes: readonly string[]): string => { const lines: string[] = [ "Execute TypeScript in a sandboxed runtime with access to configured API tools.", "", @@ -58,19 +63,19 @@ const formatDescription = (sources: readonly Source[]): string => { '2. `const path = matches[0]?.path; if (!path) return "No matching tools found.";`', "3. `const details = await tools.describe.tool({ path });`", "4. Use `details.inputTypeScript` / `details.outputTypeScript` and `details.typeScriptDefinitions` for compact shapes.", - "5. Use `tools.executor.sources.list()` when you need configured source inventory.", + "5. Use `tools.executor.coreTools.connections.list({})` when you need live saved-connection inventory.", "6. Call the tool: `const result = await tools.(input);`", "", "## Rules", "", "- `tools.search()` returns paginated, ranked matches: `{ items, total, hasMore, nextOffset }`. Best-first. Use short intent phrases like `github issues`, `repo details`, or `create calendar event`.", '- When you already know the namespace, narrow with `tools.search({ namespace: "github", query: "issues" })`.', - "- `tools.executor.sources.list()` returns the same paged shape: `{ items: [{ id, toolCount, ... }], total, hasMore, nextOffset }`.", + "- `tools.executor.coreTools.connections.list({})` returns saved connections with `{ address, integration, owner, name, ... }`. The `address` field includes the leading `tools.` root.", "- Tool calls return a value union: `{ ok: true, data }` for success or `{ ok: false, error: { code, message, status?, details?, retryable? } }` for expected tool/domain failures. Branch on `result.ok`.", - "- If `hasMore` is true and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`. Same `offset` parameter on `tools.executor.sources.list({ offset, limit })`.", - "- Always use the namespace prefix when calling tools: `tools..(args)`. Example: `tools.home_assistant_rest_api.states.getState(...)` — not `tools.states.getState(...)`.", - "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.sources.list()` instead.", - '- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.sources.list()`, and `tools.describe.tool({ path })`.', + "- If `tools.search()` returns `hasMore: true` and you didn't find what you need, fetch the next page: `tools.search({ query, offset: nextOffset, limit })`.", + "- Always use the full address when calling tools: `tools....(args)`. The `path` returned by `tools.search()` / `tools.describe.tool()` is already the exact path under `tools` — call `tools[path]` rather than guessing segments.", + "- The `tools` object is a lazy proxy — `Object.keys(tools)` won't work. Use `tools.search()` or `tools.executor.coreTools.connections.list({})` instead.", + '- Pass an object to system tools, e.g. `tools.search({ query: "..." })`, `tools.executor.coreTools.connections.list({})`, and `tools.describe.tool({ path })`.', "- `tools.describe.tool()` returns compact TypeScript shapes. Use `inputTypeScript`, `outputTypeScript`, and `typeScriptDefinitions`.", "- For tools that return large collections (e.g. `getStates`, `getAll`), filter results in code rather than calling per-item tools.", "- Do not use `fetch` — all API calls go through `tools.*`.", @@ -78,16 +83,17 @@ const formatDescription = (sources: readonly Source[]): string => { "- TypeScript type syntax (`: T`, `as T`, generics, interfaces, type aliases) is stripped before execution — feel free to write idiomatic TypeScript using the shapes from `tools.describe.tool()`. Decorators and `enum` are not supported.", ]; - if (sources.length > 0) { + if (connectionPrefixes.length > 0) { lines.push(""); - lines.push("## Available namespaces"); + lines.push("## Available connection prefixes"); lines.push(""); - const sorted = [...sources].sort((a, b) => a.id.localeCompare(b.id)).slice(0, 50); - for (const source of sorted) { - lines.push(`- \`${source.id}\``); + lines.push("These are paths under `tools.`; append the final tool segment."); + const sorted = [...connectionPrefixes].sort((a, b) => a.localeCompare(b)).slice(0, 50); + for (const prefix of sorted) { + lines.push(`- \`${prefix}\``); } - if (sources.length > sorted.length) { - lines.push(`- ... ${sources.length - sorted.length} more`); + if (connectionPrefixes.length > sorted.length) { + lines.push(`- ... ${connectionPrefixes.length - sorted.length} more`); } } diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index bc173eec2..5dee95e03 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -144,7 +144,7 @@ export const formatPausedExecution = ( kind: isUrlElicitation ? "url" : "form", message: req.message, instructions, - toolId: String(paused.elicitationContext.toolId), + address: String(paused.elicitationContext.address), args: paused.elicitationContext.args, ...(isUrlElicitation ? { url: req.url } : {}), ...(isFormElicitation ? { requestedSchema: req.requestedSchema } : {}), diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index e49d25aa7..9b217559d 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -18,7 +18,6 @@ import type { ElicitationResponse, Executor as EffectExecutor, } from "@executor-js/sdk/core"; -import { ToolId } from "@executor-js/sdk/core"; import type { Executor as PromiseExecutor } from "@executor-js/sdk/promise"; import type { CodeExecutionError, CodeExecutor, ExecuteResult } from "@executor-js/codemode-core"; @@ -52,106 +51,83 @@ export type ExecutionEngine = { }; /** - * Wrap a Promise-style executor into the Effect shape the engine consumes. + * Wrap a Promise thunk into the Effect shape the engine consumes. The Promise + * executor façade has already erased the SDK typed error channel (rejections + * carry the tagged error as the rejected value), so we re-orphan it as a defect. */ const fromPromise = (try_: () => Promise): Effect.Effect => // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Promise executor facade has already erased the SDK typed error channel Effect.tryPromise({ try: try_, catch: (cause) => cause }).pipe(Effect.orDie); -type EffectInvokeOptions = Parameters[2]; -type PromiseInvokeOptions = Parameters[2]; - -const toPromiseInvokeOptions = (options: EffectInvokeOptions): PromiseInvokeOptions => { - const onElicitation = options?.onElicitation; - if (!onElicitation) return undefined; - if (onElicitation === "accept-all") return { onElicitation }; +// --------------------------------------------------------------------------- +// wrapPromiseExecutor — adapt the v2 Promise `Executor` back into an Effect +// `Executor` so the Effect-native engine can drive it. The engine only touches +// `execute`, `tools.list`, `tools.schema`, and `integrations.list`; we wrap +// those and orphan the typed error channel. The remaining surface is filled +// with the same Promise-backed wrappers where the shapes line up so the cast to +// `EffectExecutor` is structurally honest for the methods callers can reach. +// --------------------------------------------------------------------------- - return { - onElicitation: (ctx) => - Effect.runPromise( - onElicitation({ - ...ctx, - toolId: ToolId.make(ctx.toolId), - }), - ), +const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => { + const adapter = { + integrations: { + list: () => fromPromise(() => pe.integrations.list()), + get: (slug: Parameters[0]) => + fromPromise(() => pe.integrations.get(slug)), + update: ( + slug: Parameters[0], + patch: Parameters[1], + ) => fromPromise(() => pe.integrations.update(slug, patch)), + remove: (slug: Parameters[0]) => + fromPromise(() => pe.integrations.remove(slug)), + detect: (url: Parameters[0]) => + fromPromise(() => pe.integrations.detect(url)), + }, + connections: { + create: (input: Parameters[0]) => + fromPromise(() => pe.connections.create(input)), + list: (filter?: Parameters[0]) => + fromPromise(() => pe.connections.list(filter)), + get: (ref: Parameters[0]) => + fromPromise(() => pe.connections.get(ref)), + remove: (ref: Parameters[0]) => + fromPromise(() => pe.connections.remove(ref)), + refresh: (ref: Parameters[0]) => + fromPromise(() => pe.connections.refresh(ref)), + }, + tools: { + list: (filter?: Parameters[0]) => + fromPromise(() => pe.tools.list(filter)), + schema: (address: Parameters[0]) => + fromPromise(() => pe.tools.schema(address)), + }, + providers: { + list: () => fromPromise(() => pe.providers.list()), + items: (key: Parameters[0]) => + fromPromise(() => pe.providers.items(key)), + }, + policies: { + list: () => fromPromise(() => pe.policies.list()), + create: (input: Parameters[0]) => + fromPromise(() => pe.policies.create(input)), + update: (input: Parameters[0]) => + fromPromise(() => pe.policies.update(input)), + remove: (input: Parameters[0]) => + fromPromise(() => pe.policies.remove(input)), + resolve: (address: Parameters[0]) => + fromPromise(() => pe.policies.resolve(address)), + }, + execute: ( + address: Parameters[0], + args: Parameters[1], + options?: Parameters[2], + ) => fromPromise(() => pe.execute(address, args, options)), + close: () => fromPromise(() => pe.close()), }; + // oxlint-disable-next-line executor/no-double-cast -- boundary: the Promise executor mirrors the Effect surface structurally; the engine only reaches execute/tools/integrations, all wrapped here + return adapter as unknown as EffectExecutor; }; -const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ - scopes: pe.scopes, - tools: { - invoke: (id, args, options) => - fromPromise(() => pe.tools.invoke(id, args, toPromiseInvokeOptions(options))), - list: (filter) => fromPromise(() => pe.tools.list(filter)), - schema: (id) => fromPromise(() => pe.tools.schema(id)), - definitions: () => fromPromise(() => pe.tools.definitions()), - }, - sources: { - list: () => fromPromise(() => pe.sources.list()), - remove: (input) => fromPromise(() => pe.sources.remove(input)), - refresh: (input) => fromPromise(() => pe.sources.refresh(input)), - detect: (url) => fromPromise(() => pe.sources.detect(url)), - definitions: (id) => fromPromise(() => pe.sources.definitions(id)), - configure: (input) => fromPromise(() => pe.sources.configure(input)), - listBindings: (input) => fromPromise(() => pe.sources.listBindings(input)), - resolveBinding: (input) => fromPromise(() => pe.sources.resolveBinding(input)), - setBinding: (input) => fromPromise(() => pe.sources.setBinding(input)), - removeBinding: (input) => fromPromise(() => pe.sources.removeBinding(input)), - replaceBindings: (input) => fromPromise(() => pe.sources.replaceBindings(input)), - }, - secrets: { - get: (id) => fromPromise(() => pe.secrets.get(id)), - getAtScope: (id, scope) => fromPromise(() => pe.secrets.getAtScope(id, scope)), - status: (id) => fromPromise(() => pe.secrets.status(id)), - set: (input) => fromPromise(() => pe.secrets.set(input)), - remove: (input) => fromPromise(() => pe.secrets.remove(input)), - list: () => fromPromise(() => pe.secrets.list()), - listAll: () => fromPromise(() => pe.secrets.listAll()), - usages: (id) => fromPromise(() => pe.secrets.usages(id)), - providers: () => fromPromise(() => pe.secrets.providers()), - }, - connections: { - get: (id) => fromPromise(() => pe.connections.get(id)), - getAtScope: (id, scope) => fromPromise(() => pe.connections.getAtScope(id, scope)), - list: () => fromPromise(() => pe.connections.list()), - create: (input) => fromPromise(() => pe.connections.create(input)), - updateTokens: (input) => fromPromise(() => pe.connections.updateTokens(input)), - setIdentityLabel: (id, label) => fromPromise(() => pe.connections.setIdentityLabel(id, label)), - setIdentityOverride: (input) => fromPromise(() => pe.connections.setIdentityOverride(input)), - accessToken: (id) => fromPromise(() => pe.connections.accessToken(id)), - accessTokenAtScope: (id, scope) => - fromPromise(() => pe.connections.accessTokenAtScope(id, scope)), - remove: (input) => fromPromise(() => pe.connections.remove(input)), - usages: (id) => fromPromise(() => pe.connections.usages(id)), - providers: () => fromPromise(() => pe.connections.providers()), - }, - credentialBindings: { - listForSource: (input) => fromPromise(() => pe.credentialBindings.listForSource(input)), - resolveBinding: (input) => fromPromise(() => pe.credentialBindings.resolveBinding(input)), - resolve: (input) => fromPromise(() => pe.credentialBindings.resolve(input)), - set: (input) => fromPromise(() => pe.credentialBindings.set(input)), - remove: (input) => fromPromise(() => pe.credentialBindings.remove(input)), - replaceForSource: (input) => fromPromise(() => pe.credentialBindings.replaceForSource(input)), - removeForSource: (input) => fromPromise(() => pe.credentialBindings.removeForSource(input)), - usagesForSecret: (id) => fromPromise(() => pe.credentialBindings.usagesForSecret(id)), - usagesForConnection: (id) => fromPromise(() => pe.credentialBindings.usagesForConnection(id)), - }, - oauth: { - probe: (input) => fromPromise(() => pe.oauth.probe(input)), - start: (input) => fromPromise(() => pe.oauth.start(input)), - complete: (input) => fromPromise(() => pe.oauth.complete(input)), - cancel: (sessionId, tokenScope) => fromPromise(() => pe.oauth.cancel(sessionId, tokenScope)), - }, - policies: { - list: () => fromPromise(() => pe.policies.list()), - create: (input) => fromPromise(() => pe.policies.create(input)), - update: (input) => fromPromise(() => pe.policies.update(input)), - remove: (input) => fromPromise(() => pe.policies.remove(input)), - resolve: (id) => fromPromise(() => pe.policies.resolve(id)), - }, - close: () => fromPromise(() => pe.close()), -}); - /** * Promise-wrap an Effect-native `ExecutionEngine` (from `./engine`). * Exposed separately so callers that already hold an Effect engine @@ -191,7 +167,7 @@ export const createExecutionEngine = ...`. Each test plugin below +// registers an integration + a memory credential provider, produces its tools +// through `resolveTools`, and dispatches them in `invokeTool`. The harness +// creates one `main` org connection per integration, so the sandbox-callable +// path is `.org.main.`. +// --------------------------------------------------------------------------- -const RepoInputSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(Schema.Struct({ owner: Schema.String, repo: Schema.String })), -); +const codeExecutor = makeQuickJsExecutor(); -const RepoDetailsOutputSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(Schema.Struct({ defaultBranch: Schema.String })), -); +// Standard-schema validators — used by `invokeTool` to validate args and emit +// the `Missing key` issues that surface as `invalid_tool_arguments`. +type Validator = ReturnType; -const ContactInputSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(Schema.Struct({ email: Schema.String })), +const RepoValidator: Validator = Schema.toStandardSchemaV1( + Schema.Struct({ owner: Schema.String, repo: Schema.String }), ); - -const EmptyInputSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(Schema.Struct({})), +const ContactValidator: Validator = Schema.toStandardSchemaV1( + Schema.Struct({ email: Schema.String }), ); +const EmptyValidator: Validator = Schema.toStandardSchemaV1(Schema.Struct({})); + +// Plain JSON Schema objects — stored on the produced ToolDef and rendered by +// the describe TypeScript-preview path. (ToolDef schemas are opaque JSON to +// core, exactly like the openapi plugin's spec-derived schemas.) +const RepoInputJson = { + type: "object", + properties: { owner: { type: "string" }, repo: { type: "string" } }, + required: ["owner", "repo"], +} as const; +const RepoDetailsOutputJson = { + type: "object", + properties: { defaultBranch: { type: "string" } }, + required: ["defaultBranch"], +} as const; +const ContactInputJson = { + type: "object", + properties: { email: { type: "string" } }, + required: ["email"], +} as const; +const EmptyInputJson = { type: "object", properties: {} } as const; const acceptAll = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const CONN = ConnectionName.make("main"); +const OAUTH_TEMPLATE = AuthTemplateSlug.make("oauth"); +const OAUTH_CLIENT = OAuthClientSlug.make("records-app"); + type DescribedToolContract = { readonly outputTypeScript: string; readonly typeScriptDefinitions: Record; @@ -57,185 +104,310 @@ const typeCheckDescribedInvocation = ( }); // --------------------------------------------------------------------------- -// Test plugins — each one declares a namespace as a static source with N -// tools. Handlers return static data; the suite only cares about discovery -// + elicitation flow, not real invocation semantics. +// Test plugin builder — registers one integration, produces N tools via +// resolveTools, and dispatches them in invokeTool. Handlers receive the args +// already validated against the tool's standard input schema (so invalid args +// surface as a ToolInvocationError → invalid_tool_arguments value). // --------------------------------------------------------------------------- -const githubPlugin = definePlugin(() => ({ - id: "github-test" as const, - storage: () => ({}), - staticSources: () => [ +type ToolHandlerInput = { + readonly args: unknown; + readonly elicit: Elicit; +}; + +type TestToolSpec = { + readonly name: string; + readonly description: string; + /** Plain JSON Schema stored on the produced ToolDef. */ + readonly inputJsonSchema?: unknown; + readonly outputJsonSchema?: unknown; + /** Standard-schema validator applied to args in `invokeTool`. */ + readonly validator?: Validator; + readonly handler: (input: ToolHandlerInput) => Effect.Effect; +}; + +const memoryProvider = (key: string): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make(key), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + has: (id) => Effect.sync(() => store.has(String(id))), + list: () => + Effect.sync(() => + Array.from(store.keys()).map((entryKey) => ({ + id: ProviderItemId.make(entryKey), + name: entryKey, + })), + ), + }; +}; + +const validateArgs = ( + validator: Validator | undefined, + args: unknown, +): Effect.Effect => { + if (validator == null) return Effect.succeed(args); + return Effect.promise(() => Promise.resolve(validator["~standard"].validate(args))).pipe( + Effect.flatMap((result) => + "value" in result ? Effect.succeed(result.value) : Effect.fail(result), + ), + ); +}; + +const makeTestPlugin = (config: { + readonly pluginId: string; + readonly integration: string; + readonly tools: readonly TestToolSpec[]; +}) => { + const slug = IntegrationSlug.make(config.integration); + const byName = new Map(config.tools.map((spec) => [spec.name, spec] as const)); + return definePlugin(() => ({ + id: config.pluginId, + credentialProviders: [memoryProvider(`${config.pluginId}-memory`)], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: config.tools.map( + (spec): ToolDef => ({ + name: ToolName.make(spec.name), + description: spec.description, + inputSchema: spec.inputJsonSchema, + outputSchema: spec.outputJsonSchema, + }), + ), + }), + invokeTool: ({ toolRow, args, elicit }) => { + const spec = byName.get(toolRow.name); + if (!spec) return Effect.succeed(undefined); + return validateArgs(spec.validator, args).pipe( + Effect.flatMap((decoded) => spec.handler({ args: decoded, elicit })), + ); + }, + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug, + description: config.integration, + config: {}, + }), + }), + }))(); +}; + +const githubPlugin = makeTestPlugin({ + pluginId: "github-test", + integration: "github", + tools: [ { - id: "github", - kind: "in-memory", - name: "GitHub", - tools: [ - { - name: "listRepositoryIssues", - description: "List issues for a repository", - inputSchema: RepoInputSchema, - handler: () => Effect.succeed([]), - }, - { - name: "getRepositoryDetails", - description: "Get repository details including the default branch", - inputSchema: RepoInputSchema, - outputSchema: RepoDetailsOutputSchema, - handler: () => Effect.succeed({ defaultBranch: "main" }), - }, - { - name: "searchDocs", - description: "Search GitHub API documentation", - inputSchema: EmptyInputSchema, - handler: () => Effect.succeed([]), - }, - ], + name: "listRepositoryIssues", + description: "List issues for a repository", + inputJsonSchema: RepoInputJson, + validator: RepoValidator, + handler: () => Effect.succeed([]), + }, + { + name: "getRepositoryDetails", + description: "Get repository details including the default branch", + inputJsonSchema: RepoInputJson, + validator: RepoValidator, + outputJsonSchema: RepoDetailsOutputJson, + handler: () => Effect.succeed({ defaultBranch: "main" }), + }, + { + name: "searchDocs", + description: "Search GitHub API documentation", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: () => Effect.succeed([]), }, ], -})); +}); -const crmPlugin = definePlugin(() => ({ - id: "crm-test" as const, - storage: () => ({}), - staticSources: () => [ +const crmPlugin = makeTestPlugin({ + pluginId: "crm-test", + integration: "crm", + tools: [ { - id: "crm", - kind: "in-memory", - name: "CRM", - tools: [ - { - name: "createContact", - description: "Create a CRM contact record", - inputSchema: ContactInputSchema, - handler: () => Effect.succeed({ id: "contact_1" }), - }, - { - name: "listContacts", - description: "List CRM contacts", - inputSchema: EmptyInputSchema, - handler: () => Effect.succeed([]), - }, - ], + name: "createContact", + description: "Create a CRM contact record", + inputJsonSchema: ContactInputJson, + validator: ContactValidator, + handler: () => Effect.succeed({ id: "contact_1" }), + }, + { + name: "listContacts", + description: "List CRM contacts", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: () => Effect.succeed([]), }, ], -})); +}); -const errorPlugin = definePlugin(() => ({ - id: "error-test" as const, - storage: () => ({}), - staticSources: () => [ +const errorPlugin = makeTestPlugin({ + pluginId: "error-test", + integration: "records", + tools: [ { - id: "records", - kind: "in-memory", - name: "Records", + name: "queryRows", + description: "Query rows", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: () => + Effect.succeed( + ToolResult.fail({ + code: "invalid_query", + message: 'Field with name "DisplayName" does not exist', + }), + ), + }, + ], +}); + +const oauthErrorPlugin = definePlugin(() => ({ + id: "oauth-error-test" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ tools: [ { - name: "queryRows", + name: ToolName.make("queryRows"), description: "Query rows", - inputSchema: EmptyInputSchema, - handler: () => - Effect.succeed( - ToolResult.fail({ - code: "invalid_query", - message: 'Field with name "DisplayName" does not exist', - }), - ), + inputSchema: EmptyInputJson, }, ], - }, - ], -})); + }), + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: IntegrationSlug.make("oauth_records"), + description: "OAuth records", + config: {}, + }), + }), +}))(); -const validatedInputPlugin = definePlugin(() => ({ - id: "validated-input-test" as const, - storage: () => ({}), - staticSources: () => [ +const validatedInputPlugin = makeTestPlugin({ + pluginId: "validated-input-test", + integration: "validated", + tools: [ { - id: "validated", - kind: "in-memory", - name: "Validated", - tools: [ - tool({ - name: "getRepositoryDetails", - description: "Get repository details including the default branch", - inputSchema: RepoInputSchema, - outputSchema: RepoDetailsOutputSchema, - execute: () => Effect.succeed({ defaultBranch: "main" }), - }), - ], + name: "getRepositoryDetails", + description: "Get repository details including the default branch", + inputJsonSchema: RepoInputJson, + validator: RepoValidator, + outputJsonSchema: RepoDetailsOutputJson, + handler: () => Effect.succeed({ defaultBranch: "main" }), }, ], -})); +}); -const structuredFailurePlugin = definePlugin(() => ({ - id: "structured-failure-test" as const, - storage: () => ({}), - staticSources: () => [ +const structuredFailurePlugin = makeTestPlugin({ + pluginId: "structured-failure-test", + integration: "upstream", + tools: [ { - id: "upstream", - kind: "in-memory", - name: "Upstream", - tools: [ - { - name: "nestedErrorBody", - description: "", - inputSchema: EmptyInputSchema, - handler: () => - Effect.succeed( - ToolResult.fail({ - code: "upstream_http_error", - status: 400, + name: "nestedErrorBody", + description: "", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: () => + Effect.succeed( + ToolResult.fail({ + code: "upstream_http_error", + status: 400, + message: 'The expression "foo" is not valid. Provide a valid expression.', + details: { + error: { + code: "invalidRequest", message: 'The expression "foo" is not valid. Provide a valid expression.', - details: { - error: { - code: "invalidRequest", - message: 'The expression "foo" is not valid. Provide a valid expression.', - }, - }, - }), - ), - }, - { - name: "flatErrorBody", - description: "", - inputSchema: EmptyInputSchema, - handler: () => - Effect.succeed( - ToolResult.fail({ - code: "upstream_http_error", - status: 400, - message: "Field 'XYZ' does not exist", - details: { - errorCode: 400, - errorMessage: "Field 'XYZ' does not exist", - }, - }), - ), - }, - { - name: "errorsArrayBody", - description: "", - inputSchema: EmptyInputSchema, - handler: () => - Effect.succeed( - ToolResult.fail({ - code: "upstream_http_error", - status: 403, - message: "Insufficient scope", - details: { - errors: [{ status: "403", title: "Forbidden", detail: "Insufficient scope" }], - }, - }), - ), - }, - ], + }, + }, + }), + ), + }, + { + name: "flatErrorBody", + description: "", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: () => + Effect.succeed( + ToolResult.fail({ + code: "upstream_http_error", + status: 400, + message: "Field 'XYZ' does not exist", + details: { + errorCode: 400, + errorMessage: "Field 'XYZ' does not exist", + }, + }), + ), + }, + { + name: "errorsArrayBody", + description: "", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: () => + Effect.succeed( + ToolResult.fail({ + code: "upstream_http_error", + status: 403, + message: "Insufficient scope", + details: { + errors: [{ status: "403", title: "Forbidden", detail: "Insufficient scope" }], + }, + }), + ), }, ], -})); +}); + +// Provision: register each plugin's integration and create one org `main` +// connection so per-connection tools exist and are addressable. +const provision = ( + executor: { + readonly connections: { + readonly create: (input: { + readonly owner: "org"; + readonly name: typeof CONN; + readonly integration: ReturnType; + readonly template: typeof TEMPLATE; + readonly value: string; + }) => Effect.Effect; + }; + } & Record Effect.Effect }>, + specs: readonly { readonly pluginId: string; readonly integration: string }[], +): Effect.Effect => + Effect.gen(function* () { + for (const spec of specs) { + yield* executor[spec.pluginId]!.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: IntegrationSlug.make(spec.integration), + template: TEMPLATE, + value: "token", + }); + } + }); + +const makeExecutorWith = (plugins: TPlugins) => + createExecutor(makeTestConfig({ plugins })); const makeSearchExecutor = () => - createExecutor(makeTestConfig({ plugins: [githubPlugin(), crmPlugin()] as const })); + Effect.gen(function* () { + const executor = yield* makeExecutorWith([githubPlugin, crmPlugin] as const); + yield* provision(executor as never, [ + { pluginId: "github-test", integration: "github" }, + { pluginId: "crm-test", integration: "crm" }, + ]); + return executor; + }); describe("tool discovery", () => { it.effect("ranks matches using ids, namespaces, camelCase names, and descriptions", () => @@ -244,17 +416,17 @@ describe("tool discovery", () => { const githubMatches = yield* searchTools(executor, "github issues", 5); expect(githubMatches.items.map((match) => match.path)).toEqual([ - "github.listRepositoryIssues", + "github.org.main.listRepositoryIssues", ]); expect(githubMatches.items[0]?.score ?? 0).toBeGreaterThan(0); expect(githubMatches.hasMore).toBe(false); expect(githubMatches.nextOffset).toBeNull(); const repoMatches = yield* searchTools(executor, "repo details", 5); - expect(repoMatches.items[0]?.path).toBe("github.getRepositoryDetails"); + expect(repoMatches.items[0]?.path).toBe("github.org.main.getRepositoryDetails"); const crmMatches = yield* searchTools(executor, "crm create contact", 5); - expect(crmMatches.items[0]?.path).toBe("crm.createContact"); + expect(crmMatches.items[0]?.path).toBe("crm.org.main.createContact"); expect(crmMatches.items[0]?.score ?? 0).toBeGreaterThan(crmMatches.items[1]?.score ?? 0); }), ); @@ -316,12 +488,14 @@ describe("tool discovery", () => { const githubOnly = yield* searchTools(executor, "list", 5, { namespace: "github", }); - expect(githubOnly.items.map((match) => match.path)).toEqual(["github.listRepositoryIssues"]); + expect(githubOnly.items.map((match) => match.path)).toEqual([ + "github.org.main.listRepositoryIssues", + ]); const crmOnly = yield* searchTools(executor, "list", 5, { namespace: "crm", }); - expect(crmOnly.items.map((match) => match.path)).toEqual(["crm.listContacts"]); + expect(crmOnly.items.map((match) => match.path)).toEqual(["crm.org.main.listContacts"]); const sandboxResult = yield* createExecutionEngine({ executor, codeExecutor }).execute( 'return await tools.search({ namespace: "crm", query: "create contact", limit: 5 });', @@ -330,7 +504,7 @@ describe("tool discovery", () => { expect(sandboxResult.error).toBeUndefined(); expect(sandboxResult.result).toEqual( expect.objectContaining({ - items: [expect.objectContaining({ path: "crm.createContact" })], + items: [expect.objectContaining({ path: "crm.org.main.createContact" })], total: 1, hasMore: false, nextOffset: null, @@ -355,10 +529,10 @@ describe("tool discovery", () => { return { items: [ { - path: "custom.searchResult", + path: "custom.org.main.searchResult", name: "searchResult", description: "Provided by the host", - sourceId: "custom", + integration: "custom", score: 999, }, ], @@ -390,10 +564,10 @@ describe("tool discovery", () => { expect(result.result).toEqual({ items: [ { - path: "custom.searchResult", + path: "custom.org.main.searchResult", name: "searchResult", description: "Provided by the host", - sourceId: "custom", + integration: "custom", score: 999, }, ], @@ -412,7 +586,7 @@ describe("tool discovery", () => { }), ); - it.effect("supports executor-scoped source listing and tool search", () => + it.effect("supports executor-scoped integration listing and tool search", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); @@ -440,18 +614,18 @@ describe("tool discovery", () => { expect(searched.error).toBeUndefined(); expect(searched.result).toEqual( expect.objectContaining({ - items: [expect.objectContaining({ path: "crm.listContacts" })], + items: [expect.objectContaining({ path: "crm.org.main.listContacts" })], }), ); }), ); - it.effect("paginates source listings via limit + offset", () => + it.effect("paginates integration listings via limit + offset", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); const engine = createExecutionEngine({ executor, codeExecutor }); - // total = 2 (github, crm), sorted by name ("CRM" < "GitHub") + // total = 2 (github, crm), sorted by id ("crm" < "github") const firstPage = yield* engine.execute( "return await tools.executor.sources.list({ limit: 1 });", { onElicitation: acceptAll }, @@ -525,8 +699,8 @@ describe("tool discovery", () => { Effect.gen(function* () { const executor = yield* makeSearchExecutor(); - const described = yield* describeTool(executor, "github.listRepositoryIssues"); - expect(described.path).toBe("github.listRepositoryIssues"); + const described = yield* describeTool(executor, "github.org.main.listRepositoryIssues"); + expect(described.path).toBe("github.org.main.listRepositoryIssues"); expect(described.name).toBe("listRepositoryIssues"); expect(described.description).toBe("List issues for a repository"); expect(described.inputTypeScript).toBe("{ owner: string; repo: string; }"); @@ -547,8 +721,8 @@ describe("tool discovery", () => { const execution = yield* engine.execute( [ - 'const details = await tools.describe.tool({ path: "github.getRepositoryDetails" });', - "const result = await tools.github.getRepositoryDetails({ owner: 'executor', repo: 'executor' });", + 'const details = await tools.describe.tool({ path: "github.org.main.getRepositoryDetails" });', + "const result = await tools.github.org.main.getRepositoryDetails({ owner: 'executor', repo: 'executor' });", "return {", " outputTypeScript: details.outputTypeScript,", " typeScriptDefinitions: details.typeScriptDefinitions,", @@ -579,15 +753,14 @@ describe("tool discovery", () => { "describes an error-as-value return type that accepts sandbox invocation failures", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [errorPlugin()] as const }), - ); + const executor = yield* makeExecutorWith([errorPlugin] as const); + yield* provision(executor as never, [{ pluginId: "error-test", integration: "records" }]); const engine = createExecutionEngine({ executor, codeExecutor }); const execution = yield* engine.execute( [ - 'const details = await tools.describe.tool({ path: "records.queryRows" });', - "const result = await tools.records.queryRows({});", + 'const details = await tools.describe.tool({ path: "records.org.main.queryRows" });', + "const result = await tools.records.org.main.queryRows({});", "return {", " outputTypeScript: details.outputTypeScript,", " typeScriptDefinitions: details.typeScriptDefinitions,", @@ -617,7 +790,7 @@ describe("tool discovery", () => { it.effect("describes the ToolResult wrapper through the direct describe helper", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); - const described = yield* describeTool(executor, "github.getRepositoryDetails"); + const described = yield* describeTool(executor, "github.org.main.getRepositoryDetails"); expect(described.outputTypeScript).toBe( "{ ok: true; data: { defaultBranch: string; } } | { ok: false; error: ToolError }", @@ -645,7 +818,7 @@ describe("tool discovery", () => { " sourceDetails,", " sourceResult: await tools.executor.sources.list({ limit: 2 }),", " describeDetails,", - " describeResult: await tools.describe.tool({ path: 'github.getRepositoryDetails' }),", + " describeResult: await tools.describe.tool({ path: 'github.org.main.getRepositoryDetails' }),", "};", ].join("\n"), { onElicitation: acceptAll }, @@ -709,7 +882,7 @@ describe("tool discovery", () => { const invalidDescribe = yield* engine.execute( [ "try {", - ' await tools.describe.tool({ path: "github.listRepositoryIssues", includeSchemas: true });', + ' await tools.describe.tool({ path: "github.org.main.listRepositoryIssues", includeSchemas: true });', ' return "unexpected";', "} catch (error) {", " return error instanceof Error ? error.message : String(error);", @@ -733,12 +906,13 @@ describe("tool discovery", () => { it.effect("passes ToolResult.fail through to the sandbox as a value (no throw)", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [errorPlugin()] as const })); + const executor = yield* makeExecutorWith([errorPlugin] as const); + yield* provision(executor as never, [{ pluginId: "error-test", integration: "records" }]); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); - const result = yield* invoker.invoke({ path: "records.queryRows", args: {} }); + const result = yield* invoker.invoke({ path: "records.org.main.queryRows", args: {} }); expect(result).toEqual({ ok: false, error: { @@ -749,37 +923,112 @@ describe("tool discovery", () => { }), ); + it.effect("returns OAuth reauth failures as ToolResult.fail instead of throwing", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const config = makeTestConfig({ + plugins: [memoryCredentialsPlugin(), oauthErrorPlugin] as const, + }); + const executor = yield* createExecutor(config); + yield* executor["oauth-error-test"].seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: OAUTH_CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: OAUTH_CLIENT, + clientOwner: "org", + name: CONN, + integration: IntegrationSlug.make("oauth_records"), + template: OAUTH_TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => + b.and(b("integration", "=", "oauth_records"), b("name", "=", String(CONN))), + set: { + expires_at: Date.now() - 60_000, + refresh_item_id: "missing-refresh-token", + }, + }), + ); + + const invoker = makeExecutorToolInvoker(executor, { + invokeOptions: { onElicitation: acceptAll }, + }); + const result = yield* invoker.invoke({ + path: "oauth_records.org.main.queryRows", + args: {}, + }); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "oauth_reauth_required", + message: + 'OAuth connection "oauth_records.org.main" requires reauthorization: Stored refresh token could not be resolved.', + details: { + category: "authentication", + credential: { + kind: "oauth", + label: "oauth_records.org.main", + }, + }, + retryable: false, + }, + }); + }), + ), + ); + it.effect("returns missing tool dispatches as ToolResult.fail", () => Effect.gen(function* () { - const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const })); + const executor = yield* makeExecutorWith([] as const); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); - const result = yield* invoker.invoke({ path: "missing.sourceTool", args: {} }); + const result = yield* invoker.invoke({ path: "missing.org.main.sourceTool", args: {} }); expect(result).toEqual({ ok: false, error: { code: "tool_not_found", - message: "Tool not found: missing.sourceTool", - details: { toolId: "missing.sourceTool", suggestions: [] }, + message: "Tool not found: missing.org.main.sourceTool", + details: { path: "missing.org.main.sourceTool", suggestions: [] }, }, }); }), ); - it.effect("returns invalid static tool arguments as ToolResult.fail", () => + it.effect("returns invalid tool arguments as ToolResult.fail", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [validatedInputPlugin()] as const }), - ); + const executor = yield* makeExecutorWith([validatedInputPlugin] as const); + yield* provision(executor as never, [ + { pluginId: "validated-input-test", integration: "validated" }, + ]); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); const result = yield* invoker.invoke({ - path: "validated.getRepositoryDetails", + path: "validated.org.main.getRepositoryDetails", args: { url: "https://example.com/repo" }, }); @@ -801,14 +1050,15 @@ describe("tool discovery", () => { it.effect("preserves nested upstream error bodies through ToolResult.fail", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [structuredFailurePlugin()] as const }), - ); + const executor = yield* makeExecutorWith([structuredFailurePlugin] as const); + yield* provision(executor as never, [ + { pluginId: "structured-failure-test", integration: "upstream" }, + ]); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); - const result = yield* invoker.invoke({ path: "upstream.nestedErrorBody", args: {} }); + const result = yield* invoker.invoke({ path: "upstream.org.main.nestedErrorBody", args: {} }); expect(result).toEqual({ ok: false, error: { @@ -828,14 +1078,15 @@ describe("tool discovery", () => { it.effect("preserves flat upstream error bodies through ToolResult.fail", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [structuredFailurePlugin()] as const }), - ); + const executor = yield* makeExecutorWith([structuredFailurePlugin] as const); + yield* provision(executor as never, [ + { pluginId: "structured-failure-test", integration: "upstream" }, + ]); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); - const result = yield* invoker.invoke({ path: "upstream.flatErrorBody", args: {} }); + const result = yield* invoker.invoke({ path: "upstream.org.main.flatErrorBody", args: {} }); expect(result).toEqual({ ok: false, error: { @@ -853,14 +1104,15 @@ describe("tool discovery", () => { it.effect("preserves upstream errors arrays through ToolResult.fail", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins: [structuredFailurePlugin()] as const }), - ); + const executor = yield* makeExecutorWith([structuredFailurePlugin] as const); + yield* provision(executor as never, [ + { pluginId: "structured-failure-test", integration: "upstream" }, + ]); const invoker = makeExecutorToolInvoker(executor, { invokeOptions: { onElicitation: acceptAll }, }); - const result = yield* invoker.invoke({ path: "upstream.errorsArrayBody", args: {} }); + const result = yield* invoker.invoke({ path: "upstream.org.main.errorsArrayBody", args: {} }); expect(result).toEqual({ ok: false, error: { @@ -880,60 +1132,59 @@ describe("tool discovery", () => { // pause/resume — multiple elicitations in a single execution // --------------------------------------------------------------------------- -const apiPlugin = definePlugin(() => ({ - id: "api-test" as const, - storage: () => ({}), - staticSources: () => [ +const apiPlugin = makeTestPlugin({ + pluginId: "api-test", + integration: "api", + tools: [ { - id: "api", - kind: "in-memory", - name: "API", - tools: [ - { - name: "multiApproval", - description: "A tool that elicits twice", - inputSchema: EmptyInputSchema, - handler: ({ elicit }) => - Effect.gen(function* () { - const r1 = yield* elicit( - FormElicitation.make({ - message: "First approval", - requestedSchema: {}, - }), - ); - const r2 = yield* elicit( - FormElicitation.make({ - message: "Second approval", - requestedSchema: {}, - }), - ); - return { first: r1, second: r2 }; + name: "multiApproval", + description: "A tool that elicits twice", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: ({ elicit }) => + Effect.gen(function* () { + const r1 = yield* elicit( + FormElicitation.make({ + message: "First approval", + requestedSchema: {}, }), - }, - { - name: "singleApproval", - description: - "A tool that elicits exactly once and then returns a value. Mirrors the shape of a typical `gmail.users.labels.create` style operation: one approval, one side effect, one success response.", - inputSchema: EmptyInputSchema, - handler: ({ elicit }) => - Effect.gen(function* () { - const r = yield* elicit( - FormElicitation.make({ - message: "Only approval", - requestedSchema: {}, - }), - ); - return { ok: true, response: r }; + ); + const r2 = yield* elicit( + FormElicitation.make({ + message: "Second approval", + requestedSchema: {}, }), - }, - ], + ); + return { first: r1, second: r2 }; + }), + }, + { + name: "singleApproval", + description: + "A tool that elicits exactly once and then returns a value. Mirrors the shape of a typical `gmail.users.labels.create` style operation: one approval, one side effect, one success response.", + inputJsonSchema: EmptyInputJson, + validator: EmptyValidator, + handler: ({ elicit }) => + Effect.gen(function* () { + const r = yield* elicit( + FormElicitation.make({ + message: "Only approval", + requestedSchema: {}, + }), + ); + return { ok: true, response: r }; + }), }, ], -})); +}); describe("pause/resume with multiple elicitations", () => { const makeElicitingExecutor = () => - createExecutor(makeTestConfig({ plugins: [apiPlugin()] as const })); + Effect.gen(function* () { + const executor = yield* makeExecutorWith([apiPlugin] as const); + yield* provision(executor as never, [{ pluginId: "api-test", integration: "api" }]); + return executor; + }); it.effect( "resume does not hang when execution hits a second elicitation", @@ -942,7 +1193,7 @@ describe("pause/resume with multiple elicitations", () => { const executor = yield* makeElicitingExecutor(); const engine = createExecutionEngine({ executor, codeExecutor }); - const code = "return await tools.api.multiApproval({});"; + const code = "return await tools.api.org.main.multiApproval({});"; const outcome1 = yield* engine.executeWithPause(code); expect(outcome1.status).toBe("paused"); @@ -975,9 +1226,9 @@ describe("pause/resume with multiple elicitations", () => { const code = ` return await Promise.all([ - tools.api.singleApproval({}), - tools.api.singleApproval({}), - tools.api.singleApproval({}) + tools.api.org.main.singleApproval({}), + tools.api.org.main.singleApproval({}), + tools.api.org.main.singleApproval({}) ]); `; @@ -1020,10 +1271,16 @@ describe("pause/resume with multiple elicitations", () => { // pause/resume, and a single-elicit tool so no later pause can mask a dead // sandbox fiber. it("resume returns across separate runPromise boundaries for a single-elicit tool (HTTP-like)", async () => { - const executor = await Effect.runPromise(makeElicitingExecutor()); + const executor = await Effect.runPromise( + Effect.gen(function* () { + const ex = yield* makeExecutorWith([apiPlugin] as const); + yield* provision(ex as never, [{ pluginId: "api-test", integration: "api" }]); + return ex; + }), + ); const engine = createExecutionEngine({ executor, codeExecutor }); - const code = "return await tools.api.singleApproval({});"; + const code = "return await tools.api.org.main.singleApproval({});"; const outcome1 = await Effect.runPromise(engine.executeWithPause(code)); expect(outcome1.status).toBe("paused"); diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 3853a6c2d..cf6eef721 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -2,13 +2,19 @@ import { Effect, Predicate } from "effect"; import * as Cause from "effect/Cause"; import type { Executor, - ToolId, - ToolView, - ToolSchemaView, InvokeOptions, - Source, + Integration, + ToolError, + Tool, + ToolSchemaView, +} from "@executor-js/sdk/core"; +import { + authToolFailure, + isToolResult, + ToolResult, + ToolAddress, + parseToolAddress, } from "@executor-js/sdk/core"; -import { isToolResult, ToolResult } from "@executor-js/sdk/core"; import type { SandboxToolInvoker } from "@executor-js/codemode-core"; import { ExecutionToolError } from "./errors"; @@ -26,6 +32,33 @@ const withToolResultDefinitions = ( ToolError: TOOL_ERROR_TYPESCRIPT, }); +const ADDRESS_PREFIX = "tools."; + +/** + * Map a sandbox tool path to the executor's `execute` address. + * + * v2 dynamic tools are addressed `tools....`. + * The sandbox proxy strips the leading `tools.` (the proxy root), so a model + * writing `tools.github.org.main.getRepo(args)` produces the path + * `github.org.main.getRepo`. Re-prefix it so it parses as a 5-segment address. + * + * Plugin-contributed static tools (core-tools under `executor`, plugin executor + * namespaces) are addressed by their fqid with no prefix; the executor resolves + * those from its static map directly, so leave them untouched. + */ +const pathToAddress = (path: string): ToolAddress => { + if (path.startsWith(ADDRESS_PREFIX)) return ToolAddress.make(path); + if (parseToolAddress(`${ADDRESS_PREFIX}${path}`)) { + return ToolAddress.make(`${ADDRESS_PREFIX}${path}`); + } + return ToolAddress.make(path); +}; + +/** Strip the proxy-root `tools.` prefix from a full address so it becomes the + * sandbox-callable path the model writes after `tools.`. */ +const addressToPath = (address: string): string => + address.startsWith(ADDRESS_PREFIX) ? address.slice(ADDRESS_PREFIX.length) : address; + type DescribedTool = { readonly path: string; readonly name: string; @@ -50,7 +83,7 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap = new Map< "{ items: ToolDiscoveryResult[]; total: number; hasMore: boolean; nextOffset: number | null; }", typeScriptDefinitions: { ToolDiscoveryResult: - "{ path: string; name: string; description?: string; sourceId: string; score: number; }", + "{ path: string; name: string; description?: string; integration: string; score: number; }", }, }, ], @@ -59,13 +92,13 @@ const BUILTIN_TOOL_DESCRIPTIONS: ReadonlyMap = new Map< { path: "executor.sources.list", name: "executor.sources.list", - description: "List configured and built-in Executor sources.", + description: "List configured Executor integrations.", inputTypeScript: "{ query?: string; limit?: number; offset?: number; }", outputTypeScript: "{ items: ExecutorSourceListItem[]; total: number; hasMore: boolean; nextOffset: number | null; }", typeScriptDefinitions: { ExecutorSourceListItem: - "{ id: string; name: string; kind: string; runtime?: boolean; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }", + "{ id: string; name: string; kind: string; canRemove?: boolean; canRefresh?: boolean; toolCount: number; }", }, }, ], @@ -99,22 +132,40 @@ const validationIssues = (value: unknown): readonly unknown[] | null => { return Array.isArray(issues) ? issues : null; }; -const expectedToolFailure = ( - value: unknown, -): { readonly code: string; readonly message: string; readonly details?: unknown } | null => { - if (Predicate.isTagged(value, "ToolNotFoundError") && "toolId" in value) { +const credentialResolutionToolFailure = (input: { + readonly label: string; + readonly message: string; + readonly reauthRequired?: boolean; +}) => + authToolFailure({ + code: input.reauthRequired === true ? "oauth_reauth_required" : "oauth_refresh_failed", + message: + input.reauthRequired === true + ? `OAuth connection "${input.label}" requires reauthorization: ${input.message}` + : `OAuth connection "${input.label}" could not be resolved: ${input.message}`, + credential: { + kind: "oauth", + label: input.label, + }, + }); + +const expectedToolFailure = (value: unknown): ToolError | null => { + if (Predicate.isTagged(value, "ToolNotFoundError") && "address" in value) { const suggestions = - "suggestions" in value && Array.isArray(value.suggestions) ? value.suggestions : undefined; + "suggestions" in value && Array.isArray(value.suggestions) + ? value.suggestions.map((suggestion) => addressToPath(String(suggestion))) + : undefined; + const address = addressToPath(String(value.address)); return { code: "tool_not_found", - message: `Tool not found: ${String(value.toolId)}`, - details: { toolId: value.toolId, ...(suggestions ? { suggestions } : {}) }, + message: `Tool not found: ${address}`, + details: { path: address, ...(suggestions ? { suggestions } : {}) }, }; } - if (Predicate.isTagged(value, "ToolBlockedError") && "toolId" in value) { + if (Predicate.isTagged(value, "ToolBlockedError") && "address" in value) { return { code: "tool_blocked", - message: `Tool blocked by policy: ${String(value.toolId)}`, + message: `Tool blocked by policy: ${addressToPath(String(value.address))}`, details: value, }; } @@ -132,29 +183,29 @@ const expectedToolFailure = ( }; /** - * Extract the source namespace from a tool path. Tool paths look like - * "." or ".." — we take the first - * segment as a cheap, non-lookup stand-in for the source id so the span - * attribute is always populated without hitting `executor.sources.list()` - * per call. + * Extract the integration namespace from a tool path. v2 addresses look like + * `...`; static fqids look like + * `.`. We take the first segment as a cheap, non-lookup namespace + * for the span attribute so it's always populated without a catalog read. */ -const extractSourceNamespace = (path: string): string => { - const idx = path.indexOf("."); - return idx === -1 ? path : path.slice(0, idx); +const extractNamespace = (path: string): string => { + const normalized = addressToPath(path); + const idx = normalized.indexOf("."); + return idx === -1 ? normalized : normalized.slice(0, idx); }; /** - * Bridges QuickJS `tools.someSource.someOp(args)` calls into - * `executor.tools.invoke(toolId, args)`. + * Bridges QuickJS `tools....(args)` calls + * into `executor.execute(address, args)`. * * Wrapped in `Effect.fn("mcp.tool.dispatch")` so every tool call becomes a * span in the Effect tracer. Attributes: - * - `mcp.tool.name` — full tool path (e.g. "github.repos.get") - * - `mcp.tool.source_id` — first segment of the path (namespace) + * - `mcp.tool.name` — full tool path (e.g. "github.org.main.getRepo") + * - `mcp.tool.integration` — first segment of the path (namespace) * * `mcp.tool.kind` (openapi | mcp | graphql | code) is NOT annotated here - * because it would require a `sources.list()` lookup on every invocation. - * Callers that already know the source kind can annotate at their own span. + * because it would require an `integrations.list()` lookup on every invocation. + * Callers that already know the integration kind can annotate at their own span. */ export const makeExecutorToolInvoker = ( executor: Executor, @@ -163,10 +214,20 @@ export const makeExecutorToolInvoker = ( invoke: Effect.fn("mcp.tool.dispatch")(function* ({ path, args }) { yield* Effect.annotateCurrentSpan({ "mcp.tool.name": path, - "mcp.tool.source_id": extractSourceNamespace(path), + "mcp.tool.integration": extractNamespace(path), }); - const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe( + const address = pathToAddress(path); + const result = yield* executor.execute(address, args, options.invokeOptions).pipe( + Effect.catchTag("CredentialResolutionError", (err) => + Effect.succeed( + credentialResolutionToolFailure({ + label: `${err.integration}.${err.owner}.${err.name}`, + message: err.message, + reauthRequired: err.reauthRequired, + }), + ), + ), Effect.catchCause((cause) => { const err = cause.reasons.find(Cause.isFailReason)?.error; const expected = expectedToolFailure(err); @@ -176,7 +237,7 @@ export const makeExecutorToolInvoker = ( if (isElicitationDeclinedError(err)) { return Effect.fail( new ExecutionToolError({ - message: `Tool "${err.toolId}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, + message: `Tool "${addressToPath(String(err.address))}" requires approval but the request was ${err.action === "cancel" ? "cancelled" : "declined"} by the user.`, cause: err, }), ); @@ -220,14 +281,14 @@ const isElicitationDeclinedError = ( value: unknown, ): value is { readonly _tag: "ElicitationDeclinedError"; - readonly toolId: string; + readonly address: string; readonly action: "cancel" | "decline"; } => Predicate.isTagged(value, "ElicitationDeclinedError") && value !== null && typeof value === "object" && - "toolId" in value && - typeof value.toolId === "string" && + "address" in value && + typeof value.address === "string" && "action" in value && (value.action === "cancel" || value.action === "decline"); @@ -235,7 +296,7 @@ export type ToolDiscoveryResult = { readonly path: string; readonly name: string; readonly description?: string; - readonly sourceId: string; + readonly integration: string; readonly score: number; }; @@ -243,7 +304,6 @@ export type ExecutorSourceListItem = { readonly id: string; readonly name: string; readonly kind: string; - readonly runtime?: boolean; readonly canRemove?: boolean; readonly canRefresh?: boolean; readonly toolCount: number; @@ -297,7 +357,21 @@ const paginate = (all: readonly T[], offset: number, limit: number): PagedRes }; }; -type SearchableTool = Pick; +/** What `searchTools` ranks over — the sandbox-callable path plus the v2 + * identity fields a query can match against. */ +type SearchableTool = { + readonly path: string; + readonly integration: string; + readonly name: string; + readonly description?: string; +}; + +const toSearchableTool = (tool: Tool): SearchableTool => ({ + path: addressToPath(String(tool.address)), + integration: String(tool.integration), + name: String(tool.name), + description: tool.description, +}); type PreparedField = { readonly raw: string; @@ -306,7 +380,7 @@ type PreparedField = { const SEARCH_FIELD_WEIGHTS = { path: 12, - sourceId: 8, + integration: 8, name: 10, description: 5, } as const; @@ -399,13 +473,13 @@ const matchesNamespace = (tool: SearchableTool, namespace?: string): boolean => return true; } - const sourceTokens = tokenizeSearchText(tool.sourceId); - const pathTokens = tokenizeSearchText(tool.id); + const integrationTokens = tokenizeSearchText(tool.integration); + const pathTokens = tokenizeSearchText(tool.path); const isPrefixMatch = (tokens: readonly string[]): boolean => namespaceTokens.every((token, index) => tokens[index] === token); - return isPrefixMatch(sourceTokens) || isPrefixMatch(pathTokens); + return isPrefixMatch(integrationTokens) || isPrefixMatch(pathTokens); }; const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResult | null => { @@ -416,14 +490,14 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul return null; } - const path = prepareField(tool.id); - const sourceId = prepareField(tool.sourceId); + const path = prepareField(tool.path); + const integration = prepareField(tool.integration); const name = prepareField(tool.name); const description = prepareField(tool.description); const fieldScores = [ scorePreparedField(normalizedQuery, queryTokens, path, SEARCH_FIELD_WEIGHTS.path), - scorePreparedField(normalizedQuery, queryTokens, sourceId, SEARCH_FIELD_WEIGHTS.sourceId), + scorePreparedField(normalizedQuery, queryTokens, integration, SEARCH_FIELD_WEIGHTS.integration), scorePreparedField(normalizedQuery, queryTokens, name, SEARCH_FIELD_WEIGHTS.name), scorePreparedField(normalizedQuery, queryTokens, description, SEARCH_FIELD_WEIGHTS.description), ]; @@ -462,17 +536,17 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul } if ( - normalizeSearchText(tool.id) === normalizedQuery || + normalizeSearchText(tool.path) === normalizedQuery || normalizeSearchText(tool.name) === normalizedQuery ) { score += 20; } return { - path: tool.id, + path: tool.path, name: tool.name, description: tool.description, - sourceId: tool.sourceId, + integration: tool.integration, score, }; }; @@ -512,9 +586,10 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( }), ), ); - const ranked = all - .filter((tool: ToolView) => matchesNamespace(tool, options?.namespace)) - .map((tool: ToolView) => scoreToolMatch(tool, query)) + const searchable = all.map(toSearchableTool); + const ranked = searchable + .filter((tool: SearchableTool) => matchesNamespace(tool, options?.namespace)) + .map((tool: SearchableTool) => scoreToolMatch(tool, query)) .filter(Predicate.isNotNull) .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); @@ -534,7 +609,9 @@ export const defaultToolDiscoveryProvider: ToolDiscoveryProvider = { searchTools(executor, query, limit, { namespace, offset }), }; -/** What `tools.executor.sources.list()` calls inside the sandbox. */ +/** What `tools.executor.sources.list()` calls inside the sandbox. v2: the + * "sources" are the integration catalog; tool counts come from the + * per-connection tool list. */ export const listExecutorSources = Effect.fn("executor.sources.list")(function* ( executor: Executor, options?: { @@ -546,11 +623,11 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* const normalizedQuery = normalizeSearchText(options?.query ?? ""); const limit = options?.limit ?? 50; const offset = options?.offset ?? 0; - const sources = yield* executor.sources.list().pipe( + const integrations = yield* executor.integrations.list().pipe( Effect.mapError( (cause) => new ExecutionToolError({ - message: "Failed to list executor sources", + message: "Failed to list executor integrations", cause, }), ), @@ -558,38 +635,40 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* const filtered = normalizedQuery.length === 0 - ? sources - : sources.filter((source: Source) => { - const haystack = normalizeSearchText([source.id, source.name, source.kind].join(" ")); + ? integrations + : integrations.filter((integration: Integration) => { + const haystack = normalizeSearchText( + [String(integration.slug), integration.description, integration.kind].join(" "), + ); return tokenizeSearchText(normalizedQuery).every((token) => haystack.includes(token)); }); - // Single query for all tools, then count per source in memory. + // Single query for all tools, then count per integration in memory. const allTools = yield* executor.tools.list({ includeAnnotations: false }).pipe( Effect.mapError( (cause) => new ExecutionToolError({ - message: "Failed to list tools for source counts", + message: "Failed to list tools for integration counts", cause, }), ), ); - const toolCountBySource = new Map(); + const toolCountByIntegration = new Map(); for (const tool of allTools) { - toolCountBySource.set(tool.sourceId, (toolCountBySource.get(tool.sourceId) ?? 0) + 1); + const key = String(tool.integration); + toolCountByIntegration.set(key, (toolCountByIntegration.get(key) ?? 0) + 1); } const sortedWithCounts = filtered .map( - (source: Source) => + (integration: Integration) => ({ - id: source.id, - name: source.name, - kind: source.kind, - runtime: source.runtime, - canRemove: source.canRemove, - canRefresh: source.canRefresh, - toolCount: toolCountBySource.get(source.id) ?? 0, + id: String(integration.slug), + name: String(integration.slug), + kind: integration.kind, + canRemove: integration.canRemove, + canRefresh: integration.canRefresh, + toolCount: toolCountByIntegration.get(String(integration.slug)) ?? 0, }) satisfies ExecutorSourceListItem, ) .sort((left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id)); @@ -597,7 +676,7 @@ export const listExecutorSources = Effect.fn("executor.sources.list")(function* const page = paginate(sortedWithCounts, offset, limit); yield* Effect.annotateCurrentSpan({ - "executor.sources.candidate_count": sources.length, + "executor.sources.candidate_count": integrations.length, "executor.sources.match_count": sortedWithCounts.length, "executor.sources.result_count": page.items.length, "executor.sources.has_more": page.hasMore, @@ -615,9 +694,11 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( const builtin = BUILTIN_TOOL_DESCRIPTIONS.get(path); if (builtin) return builtin; + const address = pathToAddress(path); + // Single tools.schema() call — it already fetches the tool row // internally. No need to also call tools.list() just for name/description. - const schema: ToolSchemaView | null = yield* executor.tools.schema(path); + const schema: ToolSchemaView | null = yield* executor.tools.schema(address); // tools.schema() returns null if the tool doesn't exist. Fall back to // a minimal stub so callers can still render something. @@ -625,7 +706,7 @@ export const describeTool = Effect.fn("executor.tools.describe")(function* ( return { path, name: path }; } - // The schema's id is the tool path; name/description come from the + // The schema's address is the tool address; name/description come from the // tool row which tools.schema() already loaded. return { path, diff --git a/packages/core/integrations-registry/CHANGELOG.md b/packages/core/integrations-registry/CHANGELOG.md index a7da5e4d3..7c6662121 100644 --- a/packages/core/integrations-registry/CHANGELOG.md +++ b/packages/core/integrations-registry/CHANGELOG.md @@ -1,6 +1 @@ -# @executor-js/integrations-registry changelog - -This file exists for `changesets/action@v1` compatibility (it reads every -workspace package's `CHANGELOG.md` to build the Version Packages PR). -Canonical user-facing release notes are at `apps/cli/release-notes/next.md` -and on the GitHub Releases page. +# @executor-js/integrations-registry diff --git a/packages/core/sdk/CHANGELOG.md b/packages/core/sdk/CHANGELOG.md index 548ecf4ce..09d105383 100644 --- a/packages/core/sdk/CHANGELOG.md +++ b/packages/core/sdk/CHANGELOG.md @@ -1,4 +1,13 @@ -# @executor-js/core changelog +# @executor-js/sdk -This file exists for Changesets release workflow compatibility. -Canonical user-facing release notes are published on GitHub Releases. +## 1.5.2 + +## 1.5.1 + +## 1.5.0 + +### Patch Changes + +- [#893](https://github.com/RhysSullivan/executor/pull/893) [`7d7fbbd`](https://github.com/RhysSullivan/executor/commit/7d7fbbda9c0912e70334dcc809ec755ba3328f68) Thanks [@dmmulroy](https://github.com/dmmulroy)! - Batch OpenAPI operation metadata writes through plugin storage so adding large built-in OpenAPI sources no longer performs thousands of sequential D1 operations. + +- [#922](https://github.com/RhysSullivan/executor/pull/922) [`1ba0193`](https://github.com/RhysSullivan/executor/commit/1ba01932919e6aee25a76c4c093841df8539adad) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Move `effect` from `dependencies` to `peerDependencies` in the published library packages so consumers provide a single shared Effect instance. diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 05f3f94c6..334ae1f44 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/sdk", - "version": "1.4.33", + "version": "1.5.2", "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/sdk", "bugs": { "url": "https://github.com/RhysSullivan/executor/issues" @@ -20,10 +20,10 @@ "./core": "./src/index.ts", "./shared": "./src/shared.ts", "./host-internal": "./src/host-internal.ts", - "./http-source": "./src/http-source.ts", "./promise": "./src/promise.ts", "./client": "./src/client.ts", - "./testing": "./src/testing.ts" + "./testing": "./src/testing.ts", + "./migration": "./src/migration-spec.ts" }, "publishConfig": { "access": "public", @@ -52,12 +52,6 @@ "default": "./dist/host-internal.js" } }, - "./http-source": { - "import": { - "types": "./dist/http-source.d.ts", - "default": "./dist/http-source.js" - } - }, "./client": { "import": { "types": "./dist/client.d.ts", @@ -69,6 +63,12 @@ "types": "./dist/testing.d.ts", "default": "./dist/testing.js" } + }, + "./migration": { + "import": { + "types": "./dist/migration-spec.d.ts", + "default": "./dist/migration-spec.js" + } } } }, @@ -80,7 +80,6 @@ }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "effect": "catalog:", "fractional-indexing": "^3.2.0", "fumadb": "workspace:*", "oauth4webapi": "^3.8.5" @@ -93,6 +92,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "drizzle-orm": "catalog:", + "effect": "catalog:", "react": "catalog:", "tsup": "catalog:", "typescript": "catalog:", @@ -101,6 +101,7 @@ "peerDependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", + "effect": "catalog:", "react": "catalog:" }, "peerDependenciesMeta": { diff --git a/packages/core/sdk/src/auth-tool-failure.ts b/packages/core/sdk/src/auth-tool-failure.ts index b43fad8a6..cef04006b 100644 --- a/packages/core/sdk/src/auth-tool-failure.ts +++ b/packages/core/sdk/src/auth-tool-failure.ts @@ -1,11 +1,10 @@ import { ToolResult, type ToolError } from "./tool-result"; export type AuthToolFailureCode = - | "credential_binding_missing" - | "credential_secret_missing" - | "credential_rejected" + | "connection_value_missing" + | "connection_rejected" | "oauth_connection_missing" - | "oauth_connection_failed" + | "oauth_refresh_failed" | "oauth_reauth_required"; export type AuthToolFailureInput = { @@ -16,7 +15,7 @@ export type AuthToolFailureInput = { readonly scope?: string; }; readonly credential?: { - readonly kind: "secret" | "connection" | "oauth" | "upstream"; + readonly kind: "secret" | "oauth" | "upstream"; readonly label?: string; readonly slotKey?: string; readonly secretId?: string; @@ -32,16 +31,21 @@ export type AuthToolFailureInput = { }; }; +// In v1.5 a connection IS the credential: there is no standalone secret to +// "bind" to a source afterward. Manually-entered credentials are created via +// the connection handoff (the user enters the value in the web UI, which +// creates the bound connection in one step); OAuth credentials are minted by +// the OAuth start flow. These strings are read by the agent resolving the +// failure, so they must name tools that actually exist on the executor. const authRecovery = (input?: AuthToolFailureInput["recovery"]) => ({ - secretsUrl: "https://executor.sh/secrets", - createSecretTool: "executor.coreTools.secrets.create", + createConnectionTool: "executor.coreTools.connections.createHandoff", startOAuthTool: "executor.coreTools.oauth.start", listConnectionsTool: "executor.coreTools.connections.list", ...(input?.configureSourceTool ? { configureSourceTool: input.configureSourceTool } : {}), - secretInstructions: - "For API keys, tokens, and other manually entered credentials, call createSecretTool and give the returned browser URL to the user before configuring the source binding.", + connectionInstructions: + "For API keys and tokens, call createConnectionTool for the integration to get a browser URL; the user enters the credential there, which creates the bound connection. Do not ask the user to paste secrets into chat. Then call listConnectionsTool to confirm the connection exists before retrying this tool.", oauthInstructions: - "For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user, then bind the completed connection with the source configuration tool.", + "For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user. The completed connection binds automatically, then retry the tool.", }); export const authToolFailure = (input: AuthToolFailureInput): ToolResult => { diff --git a/packages/core/sdk/src/blob.test.ts b/packages/core/sdk/src/blob.test.ts index 850a3bcaa..59e5a75d1 100644 --- a/packages/core/sdk/src/blob.test.ts +++ b/packages/core/sdk/src/blob.test.ts @@ -3,93 +3,98 @@ import { Effect } from "effect"; import { StorageError } from "./fuma-runtime"; -import { makeInMemoryBlobStore, pluginBlobStore } from "./blob"; +import { makeInMemoryBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob"; + +// v2: owner partitions instead of a scope stack. Reads fall through +// [user, org] (user = innermost); writes/deletes name an explicit owner. +const partitions = (org: string, user: string | null): OwnerPartitions => ({ + org, + user, +}); describe("pluginBlobStore", () => { - it.effect("get returns innermost scope's value when both scopes have one", () => + it.effect("get returns user (innermost) value when both owners have one", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - yield* store.put("inner/my-plugin", "k", "inner-value"); - yield* store.put("outer/my-plugin", "k", "outer-value"); + yield* store.put("u/my-plugin", "k", "user-value"); + yield* store.put("o/my-plugin", "k", "org-value"); - const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); const value = yield* plugin.get("k"); - expect(value).toBe("inner-value"); + expect(value).toBe("user-value"); }), ); - it.effect("get falls through to outer scope when inner is empty", () => + it.effect("get falls through to org when user partition is empty", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - yield* store.put("outer/my-plugin", "k", "outer-value"); + yield* store.put("o/my-plugin", "k", "org-value"); - const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); const value = yield* plugin.get("k"); - expect(value).toBe("outer-value"); + expect(value).toBe("org-value"); }), ); - it.effect("get returns null when no scope has the key", () => + it.effect("get returns null when no owner has the key", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); const value = yield* plugin.get("k"); expect(value).toBeNull(); }), ); - it.effect("has returns true when any scope has the key", () => + it.effect("has returns true when any owner has the key", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - yield* store.put("outer/my-plugin", "k", "v"); + yield* store.put("o/my-plugin", "k", "v"); - const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); const found = yield* plugin.has("k"); expect(found).toBe(true); }), ); - it.effect("has returns false when no scope has the key", () => + it.effect("has returns false when no owner has the key", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); + const plugin = pluginBlobStore(store, partitions("o", "u"), "my-plugin"); const found = yield* plugin.has("k"); expect(found).toBe(false); }), ); - it.effect("namespaces are keyed by scope/pluginId — different plugins don't collide", () => + it.effect("namespaces are keyed by partition/pluginId — different plugins don't collide", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - yield* store.put("inner/plugin-a", "k", "a-value"); - yield* store.put("inner/plugin-b", "k", "b-value"); + yield* store.put("u/plugin-a", "k", "a-value"); + yield* store.put("u/plugin-b", "k", "b-value"); - const pluginA = pluginBlobStore(store, ["inner"], "plugin-a"); - const pluginB = pluginBlobStore(store, ["inner"], "plugin-b"); + const pluginA = pluginBlobStore(store, partitions("o", "u"), "plugin-a"); + const pluginB = pluginBlobStore(store, partitions("o", "u"), "plugin-b"); expect(yield* pluginA.get("k")).toBe("a-value"); expect(yield* pluginB.get("k")).toBe("b-value"); }), ); - it.effect("put rejects scope outside the stack", () => + it.effect("put rejects owner:user when the executor has no subject", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - const plugin = pluginBlobStore(store, ["inner", "outer"], "my-plugin"); - const err = yield* plugin.put("k", "v", { scope: "not-in-stack" }).pipe(Effect.flip); + // No user partition → owner:"user" writes fail. + const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin"); + const err = yield* plugin.put("k", "v", { owner: "user" }).pipe(Effect.flip); expect(err).toBeInstanceOf(StorageError); - expect(err).toMatchObject({ - message: expect.stringContaining("not in the"), - }); // Write must not have reached the store. - expect(yield* store.get("not-in-stack/my-plugin", "k")).toBeNull(); + expect(yield* store.get("o/my-plugin", "k")).toBeNull(); }), ); - it.effect("delete rejects scope outside the stack", () => + it.effect("delete rejects owner:user when the executor has no subject", () => Effect.gen(function* () { const store = makeInMemoryBlobStore(); - const plugin = pluginBlobStore(store, ["inner"], "my-plugin"); - const err = yield* plugin.delete("k", { scope: "not-in-stack" }).pipe(Effect.flip); + const plugin = pluginBlobStore(store, partitions("o", null), "my-plugin"); + const err = yield* plugin.delete("k", { owner: "user" }).pipe(Effect.flip); expect(err).toBeInstanceOf(StorageError); }), ); diff --git a/packages/core/sdk/src/blob.ts b/packages/core/sdk/src/blob.ts index af8060abe..a11c7cbbb 100644 --- a/packages/core/sdk/src/blob.ts +++ b/packages/core/sdk/src/blob.ts @@ -18,6 +18,7 @@ import { Effect } from "effect"; import { StorageError, type IFumaClient } from "./fuma-runtime"; +import type { Owner } from "./ids"; export interface BlobStore { readonly get: (namespace: string, key: string) => Effect.Effect; @@ -40,77 +41,86 @@ export interface BlobStore { } export interface PluginBlobStore { - /** Walk the scope stack (innermost first) and return the first - * non-null value for `key`. */ + /** Read precedence: this subject's own (`user`) value first, then the + * org-shared value. Returns the first non-null. */ readonly get: (key: string) => Effect.Effect; - /** Write `value` under `key` at the named scope. Scope must be one - * of the executor's configured scopes. */ + /** Write `value` under `key` for the named owner (`"org"` shared, `"user"` + * private). `"user"` requires the executor to be bound to a subject. */ readonly put: ( key: string, value: string, - options: { readonly scope: string }, + options: { readonly owner: Owner }, ) => Effect.Effect; - /** Delete `key` at the named scope. */ + /** Delete `key` for the named owner. */ readonly delete: ( key: string, - options: { readonly scope: string }, + options: { readonly owner: Owner }, ) => Effect.Effect; - /** Walk the scope stack and return true if any scope has a value for `key`. */ + /** True if either the user or org partition has a value for `key`. */ readonly has: (key: string) => Effect.Effect; } -const assertScope = (scope: string, scopes: readonly string[]): Effect.Effect => - scopes.includes(scope) - ? Effect.void - : Effect.fail( - new StorageError({ - message: - `Blob write targets scope "${scope}" which is not in the ` + - `executor's scope stack [${scopes.join(", ")}].`, - cause: undefined, - }), - ); +/** The owner partition strings an executor binding resolves to: the org + * partition (always present) and this subject's user partition (null for a + * pure-org executor). Reads walk `[user, org]`; writes target one. */ +export interface OwnerPartitions { + readonly org: string; + readonly user: string | null; +} -const nsFor = (scope: string, pluginId: string) => `${scope}/${pluginId}`; +const nsFor = (partition: string, pluginId: string) => `${partition}/${pluginId}`; /** - * Bind a `BlobStore` to a specific scope stack and plugin id. Reads - * fall through the stack; writes require an explicit scope. Used by - * the executor to build the `blobs` field handed to each plugin's - * `storage` factory. + * Bind a `BlobStore` to an owner partitioning + plugin id. Reads fall through + * `[user, org]` (user first); writes target an explicit owner. Used by the + * executor to build the `blobs` field handed to each plugin's `storage` factory. */ export const pluginBlobStore = ( store: BlobStore, - scopes: readonly string[], + partitions: OwnerPartitions, pluginId: string, -): PluginBlobStore => ({ - get: (key) => - Effect.gen(function* () { - const namespaces = scopes.map((s) => nsFor(s, pluginId)); - const hits = yield* store.getMany(namespaces, key); - if (hits.size === 0) return null; - for (const ns of namespaces) { - const v = hits.get(ns); - if (v !== undefined) return v; - } - return null; - }), - put: (key, value, options) => - Effect.flatMap(assertScope(options.scope, scopes), () => - store.put(nsFor(options.scope, pluginId), key, value), - ), - delete: (key, options) => - Effect.flatMap(assertScope(options.scope, scopes), () => - store.delete(nsFor(options.scope, pluginId), key), - ), - has: (key) => - store - .getMany( - scopes.map((s) => nsFor(s, pluginId)), - key, - ) - .pipe(Effect.map((hits) => hits.size > 0)), -}); +): PluginBlobStore => { + const readNamespaces = (): readonly string[] => + (partitions.user == null ? [partitions.org] : [partitions.user, partitions.org]).map((p) => + nsFor(p, pluginId), + ); + + const partitionFor = (owner: Owner): Effect.Effect => { + if (owner === "org") return Effect.succeed(partitions.org); + if (partitions.user == null) { + return Effect.fail( + new StorageError({ + message: 'Blob write targets owner "user" but the executor has no subject.', + cause: undefined, + }), + ); + } + return Effect.succeed(partitions.user); + }; + + return { + get: (key) => + Effect.gen(function* () { + const namespaces = readNamespaces(); + const hits = yield* store.getMany(namespaces, key); + if (hits.size === 0) return null; + for (const ns of namespaces) { + const v = hits.get(ns); + if (v !== undefined) return v; + } + return null; + }), + put: (key, value, options) => + Effect.flatMap(partitionFor(options.owner), (partition) => + store.put(nsFor(partition, pluginId), key, value), + ), + delete: (key, options) => + Effect.flatMap(partitionFor(options.owner), (partition) => + store.delete(nsFor(partition, pluginId), key), + ), + has: (key) => store.getMany(readNamespaces(), key).pipe(Effect.map((hits) => hits.size > 0)), + }; +}; /** * Minimal in-memory BlobStore — good for tests and trivial hosts. Real diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index 29614fd3a..35e35d72c 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -77,18 +77,18 @@ export interface WidgetDecl { export type SlotComponent = ComponentType>; // --------------------------------------------------------------------------- -// SourcePlugin / SourcePreset — UI contract for plugins that expose -// "sources" (OpenAPI specs, MCP servers, GraphQL endpoints, etc.). The -// host owns the source list / detail chrome; the plugin owns the +// IntegrationPlugin / IntegrationPreset — UI contract for plugins that expose +// "integrations" (OpenAPI specs, MCP servers, GraphQL endpoints, etc.). The +// host owns the integration list / detail chrome; the plugin owns the // add-flow, edit form, and (optional) summary + sign-in buttons. // // Lives here, not in `@executor-js/react`, so it's part of the plugin -// contract: a plugin's `./client` entry assembles its `sourcePlugin` +// contract: a plugin's `./client` entry assembles its `integrationPlugin` // alongside `pages`/`widgets`, and the host derives the union list // from `virtual:executor/plugins-client`. // --------------------------------------------------------------------------- -export interface SourcePreset { +export interface IntegrationPreset { /** Unique id (e.g. "stripe", "github-graphql"). */ readonly id: string; readonly name: string; @@ -101,16 +101,30 @@ export interface SourcePreset { readonly endpoint?: string; /** Optional icon URL (favicon, logo). */ readonly icon?: string; - /** Shown in the top-level grid on the sources page when true. */ + /** Shown in the top-level grid on the integrations page when true. */ readonly featured?: boolean; } -export interface SourcePlugin { +export interface IntegrationAccountHandoff { + /** Changes on each handoff URL, so the accounts UI can open once per link. */ + readonly key: string; + readonly owner?: "org" | "user"; + /** Auth template/method to preselect when present. */ + readonly template?: string; + /** Non-secret connection label to prefill. */ + readonly label?: string; +} + +export interface IntegrationPlugin { /** Unique key matching the SDK plugin id (e.g. "openapi"). */ readonly key: string; readonly label: string; readonly add: ComponentType<{ - readonly onComplete: () => void; + /** Called when the integration has been registered. Receives the slug of + * the just-registered integration, so the host can route to its detail + * hub (`/integrations/`). Optional so existing no-arg calls still + * typecheck while plugins are threading the slug through. */ + readonly onComplete: (slug?: string) => void; readonly onCancel: () => void; readonly initialUrl?: string; readonly initialPreset?: string; @@ -125,7 +139,15 @@ export interface SourcePlugin { readonly variant?: "badge" | "panel"; readonly onAction?: () => void; }>; - readonly presets?: readonly SourcePreset[]; + /** Renders the integration's Accounts hub (auth methods + connections) inside + * the detail page's Accounts tab. Plugins that declare auth methods implement + * this; the page falls back to a generic accounts list when absent. */ + readonly accounts?: ComponentType<{ + readonly sourceId: string; + readonly integrationName: string; + readonly accountHandoff?: IntegrationAccountHandoff | null; + }>; + readonly presets?: readonly IntegrationPreset[]; /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). * Call from the host on intent (hover/focus) so the chunks land before the * user navigates into the add page. Idempotent. */ @@ -150,11 +172,11 @@ export interface ClientPluginSpec { readonly pages?: readonly PageDecl[]; readonly widgets?: readonly WidgetDecl[]; readonly slots?: Record; - /** Source plugin contribution — populated by plugins that expose + /** Integration plugin contribution — populated by plugins that expose * `kind` rows in the core `source` table (openapi, mcp, graphql). - * The host's sources page derives its provider - * list from the union of every loaded plugin's `sourcePlugin`. */ - readonly sourcePlugin?: SourcePlugin; + * The host's integrations page derives its provider + * list from the union of every loaded plugin's `integrationPlugin`. */ + readonly integrationPlugin?: IntegrationPlugin; /** Secret provider plugin contribution — populated by plugins that * also ship a `secretProviders` (or related) server-side capability * AND want to expose a settings card on the host's secrets page. */ @@ -271,7 +293,7 @@ export const createPluginAtomClient = < // // The host wraps once at the root of its tree (typically reading from // `virtual:executor/plugins-client`); pages and shared components consume -// via the focused hooks (`useSourcePlugins` etc.) so they don't import +// via the focused hooks (`useIntegrationPlugins` etc.) so they don't import // from any host-app aggregator file. Pages stay portable across hosts — // the same component renders against whatever plugin set the surrounding // `` provides. @@ -282,7 +304,7 @@ export const createPluginAtomClient = < interface ExecutorPluginsContextValue { readonly plugins: readonly ClientPluginSpec[]; - readonly sourcePlugins: readonly SourcePlugin[]; + readonly integrationPlugins: readonly IntegrationPlugin[]; readonly secretProviderPlugins: readonly SecretProviderPlugin[]; } @@ -301,18 +323,20 @@ export function ExecutorPluginsProvider( const value = useMemo( () => ({ plugins, - sourcePlugins: plugins.flatMap((p) => (p.sourcePlugin ? [p.sourcePlugin] : [])), + integrationPlugins: plugins.flatMap((p) => + p.integrationPlugin ? [p.integrationPlugin] : [], + ), secretProviderPlugins: plugins.flatMap((p) => p.secretProviderPlugin ? [p.secretProviderPlugin] : [], ), }), [plugins], ); - // Kick off lazy chunk downloads for every source plugin once the host + // Kick off lazy chunk downloads for every integration plugin once the host // mounts, so navigating into an add/edit page doesn't suspend. useEffect(() => { - for (const sp of value.sourcePlugins) sp.preload?.(); - }, [value.sourcePlugins]); + for (const ip of value.integrationPlugins) ip.preload?.(); + }, [value.integrationPlugins]); return createElement(ExecutorPluginsContext.Provider, { value }, children); } @@ -329,9 +353,9 @@ const usePluginsCtx = (hookName: string): ExecutorPluginsContextValue => { export const useClientPlugins = (): readonly ClientPluginSpec[] => usePluginsCtx("useClientPlugins").plugins; -/** Source plugins extracted from `clientPlugins[].sourcePlugin`. */ -export const useSourcePlugins = (): readonly SourcePlugin[] => - usePluginsCtx("useSourcePlugins").sourcePlugins; +/** Integration plugins extracted from `clientPlugins[].integrationPlugin`. */ +export const useIntegrationPlugins = (): readonly IntegrationPlugin[] => + usePluginsCtx("useIntegrationPlugins").integrationPlugins; /** Secret-provider plugins extracted from `clientPlugins[].secretProviderPlugin`. */ export const useSecretProviderPlugins = (): readonly SecretProviderPlugin[] => diff --git a/packages/core/sdk/src/connection-name-identifier.ts b/packages/core/sdk/src/connection-name-identifier.ts new file mode 100644 index 000000000..e9f2056c2 --- /dev/null +++ b/packages/core/sdk/src/connection-name-identifier.ts @@ -0,0 +1,18 @@ +import { ConnectionName } from "./ids"; + +export const isConnectionIdentifier = (value: string): boolean => + /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value); + +export const connectionIdentifier = (input: string, fallback = "connection"): ConnectionName => { + const words = input.toLowerCase().match(/[a-z0-9]+/g); + const base = + words + ?.map((word, index) => + index === 0 ? word : `${word[0]?.toUpperCase() ?? ""}${word.slice(1)}`, + ) + .join("") || fallback; + + return ConnectionName.make( + /^[A-Za-z_$]/.test(base) ? base : `${fallback}${base[0]?.toUpperCase() ?? ""}${base.slice(1)}`, + ); +}; diff --git a/packages/core/sdk/src/connection.ts b/packages/core/sdk/src/connection.ts new file mode 100644 index 000000000..e5beb0106 --- /dev/null +++ b/packages/core/sdk/src/connection.ts @@ -0,0 +1,90 @@ +import type { + AuthTemplateSlug, + ConnectionAddress, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + Owner, + ProviderItemId, + ProviderKey, +} from "./ids"; + +/* A Connection is THE saved credential — secret, account, and connection are one + * concept — bound to exactly ONE integration (born wired; there is no unwired + * state and no separate "connect" step). Named, owner-scoped. Its value lives in + * a provider (the default store for pasted values, or an external one like + * 1Password) and is applied to the integration's template lazily, per call — + * never pre-baked. Reusing a credential across a provider's APIs is a property of + * the integration grain (bundle the provider), not of the connection. */ + +export interface Connection { + readonly owner: Owner; + readonly name: ConnectionName; + /** The one integration this credential is for. */ + readonly integration: IntegrationSlug; + /** Which of the integration's auth methods this credential is applied through. */ + readonly template: AuthTemplateSlug; + /** Which backend resolves the value — the default store, or e.g. "1password". + * Never the value itself. */ + readonly provider: ProviderKey; + /** Callable handle `tools...`. Append `.` + * to reach one of its tools. */ + readonly address: ConnectionAddress; + /** Optional human label (which account). Not load-bearing. */ + readonly identityLabel?: string | null; + /** Epoch ms when an OAuth access token expires; null/absent for static creds. */ + readonly expiresAt?: number | null; + /** The OAuth app (`oauth_client` slug) that minted this connection, when it + * came from an OAuth flow; null for static credentials. Lets the UI map a + * connection back to the app backing it. Never a secret — just the slug. */ + readonly oauthClient?: OAuthClientSlug | null; + /** The OWNER of `oauthClient` — a Personal connection may be minted through a + * shared Workspace app, so the app's owner differs from this connection's. + * Stored at mint so refresh/reconnect load the client by an explicit + * `(slug, owner)` instead of re-deriving the owner. Null for static creds. */ + readonly oauthClientOwner?: Owner | null; + /** The scope set the provider actually GRANTED (space-delimited), recorded at + * connect/refresh. Load-bearing: compared against the integration's currently + * declared scopes to decide whether this connection must reconnect to grant + * newly-needed access. Null for static creds / when the AS omitted `scope`. */ + readonly oauthScope?: string | null; +} + +/** Identify one connection — unique by (owner, integration, name). */ +export interface ConnectionRef { + readonly owner: Owner; + readonly name: ConnectionName; + readonly integration: IntegrationSlug; +} + +/** Where a single credential input comes from. `value` is pasted raw and written + * to the default provider; `from` references an external provider (1Password, + * keychain) by opaque id — we store the routing and resolve on demand, never + * holding the value. Applied to a template lazily, never pre-baked into + * `Bearer …`. */ +export type ConnectionInputOrigin = + | { readonly value: string } + | { readonly from: { readonly provider: ProviderKey; readonly id: ProviderItemId } }; + +/** The value origin(s) for a new credential. A connection resolves a MAP of named + * inputs (`variable → value`); a single-secret connection uses the one `token` + * variable, an apiKey method with two distinct inputs (e.g. Datadog) carries one + * per variable. `value` / `from` are sugar for the single `token` input; `values` + * is pasted multi-input; `inputs` is the canonical per-variable origin map (mixes + * pasted + external). All inputs of one connection share one provider. */ +export type ConnectionValueInput = + | { readonly value: string } + | { readonly from: { readonly provider: ProviderKey; readonly id: ProviderItemId } } + | { readonly values: Record } + | { readonly inputs: Record }; + +/** Save a credential for one integration (born wired). `template` picks which of + * the integration's auth methods to apply it through. For OAuth, use + * `oauth.start` instead. */ +export type CreateConnectionInput = { + readonly owner: Owner; + readonly name: ConnectionName; + readonly integration: IntegrationSlug; + readonly template: AuthTemplateSlug; + readonly identityLabel?: string | null; +} & ConnectionValueInput; diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index f77413a20..a183ac501 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -1,1085 +1,377 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Fiber, Predicate } from "effect"; +import { Effect, Predicate, Result } from "effect"; import { - ConnectionRefreshError, - CreateConnectionInput, - RemoveConnectionInput, - TokenMaterial, - UpdateConnectionTokensInput, - type ConnectionProvider, - type ConnectionRefreshInput, - type ConnectionRefreshResult, -} from "./connections"; -import { createExecutor } from "./executor"; -import { ConnectionId, ScopeId, SecretId } from "./ids"; + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; import { definePlugin } from "./plugin"; -import { Scope } from "./scope"; -import { RemoveSecretInput, SetSecretInput, type SecretProvider } from "./secrets"; -import { makeTestConfig } from "./testing"; +import type { CredentialProvider } from "./provider"; +import { makeTestExecutor } from "./testing"; -// --------------------------------------------------------------------------- -// Shared fixture helpers. Each test builds its own plugin stack so refresh -// handlers and captured provider inputs stay isolated. -// --------------------------------------------------------------------------- +// removed: v1 connection-refresh lifecycle, ConnectionProvider.refresh, +// SecretProvider, accessToken token-refresh + in-flight dedup tests — the v2 +// model folds secret/connection into one provider-resolved Connection, and OAuth +// refresh is core's responsibility (stubbed for milestone 1). The cases below +// cover the v2 connection surface: create (inline + external), list, get, +// remove, refresh, and per-connection tool production. -const makeMemoryProvider = (): SecretProvider => { +const memoryProvider = (): CredentialProvider => { const store = new Map(); - const key = (scope: string, id: string) => `${scope}\u0000${id}`; return { - key: "memory", + key: ProviderKey.make("memory"), writable: true, - get: (id, scope) => Effect.sync(() => store.get(key(scope, id)) ?? null), - set: (id, value, scope) => - Effect.sync(() => { - store.set(key(scope, id), value); - }), - delete: (id, scope) => Effect.sync(() => store.delete(key(scope, id))), + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + has: (id) => Effect.sync(() => store.has(String(id))), list: () => Effect.sync(() => - Array.from(store.keys()).map((k) => { - const name = k.split("\u0000", 2)[1] ?? k; - return { id: name, name }; - }), + Array.from(store.keys()).map((key) => ({ + id: ProviderItemId.make(key), + name: key, + })), ), }; }; -const memorySecretsPlugin = (provider: SecretProvider = makeMemoryProvider()) => - definePlugin(() => ({ - id: "memory-secrets" as const, - storage: () => ({}), - secretProviders: [provider], - }))(); - -// Connection provider factory that records every refresh call and returns -// whatever result the test asked for. The `refresh` handler is optional — -// tests that exercise "no refresh" behavior omit it. -const makeConnectionProvider = (opts: { - key: string; - refresh?: (input: ConnectionRefreshInput) => ConnectionRefreshResult; -}) => { - const calls: ConnectionRefreshInput[] = []; - const provider: ConnectionProvider = { - key: opts.key, - ...(opts.refresh - ? { - refresh: (input) => - Effect.sync(() => { - calls.push(input); - return opts.refresh!(input); - }), - } - : {}), - }; - return { provider, calls }; -}; - -const connPlugin = (provider: ConnectionProvider) => - definePlugin(() => ({ - id: "conn-test" as const, - storage: () => ({}), - connectionProviders: [provider], - }))(); - -const sid = (s: string) => SecretId.make(s); -const cid = (s: string) => ConnectionId.make(s); -const scpid = (s: string) => ScopeId.make(s); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("connections", () => { - it.effect("create + get + list round-trips", () => - Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - const created = yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: "alice", - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "access-v1", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "refresh-v1", - }), - expiresAt: Date.now() + 3_600_000, - oauthScope: "user-read", - providerState: null, - }), - ); - expect(created.id).toBe(cid("conn-1")); - expect(created.identityLabel).toBe("alice"); - - const got = yield* executor.connections.get("conn-1"); - expect(got?.id).toBe(cid("conn-1")); - expect(got?.accessTokenSecretId).toBe(sid("conn-1.access")); - - const list = yield* executor.connections.list(); - expect(list.map((r) => r.id)).toEqual([cid("conn-1")]); +const INTEG = IntegrationSlug.make("vercel"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); + +const demoPlugin = definePlugin(() => ({ + id: "demo" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [ + { name: ToolName.make("deploy"), description: "deploy" }, + { name: ToolName.make("list"), description: "list" }, + ], }), - ); - - it.effect("create fails when the provider is not registered", () => - Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin()] as const, - }), - ); - - const err = yield* executor.connections - .create( - CreateConnectionInput.make({ - id: cid("conn-x"), - scope: scpid("test-scope"), - provider: "unregistered", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-x.access"), - name: "access", - value: "a", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ) - .pipe(Effect.flip); - expect(Predicate.isTagged(err, "ConnectionProviderNotRegisteredError")).toBe(true); - }), - ); - - it.effect("create fails when the target scope is outside the stack", () => - Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); + invokeTool: ({ toolRow, credential }) => + Effect.succeed({ ran: toolRow.name, value: credential.value }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Vercel", + config: {}, + }), + resolveValue: (owner: "org" | "user", name: string) => + ctx.connections.resolveValue({ + owner, + integration: INTEG, + name: ConnectionName.make(name), + }), + }), +}))(); - const result = yield* Effect.exit( - executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-y"), - scope: scpid("not-in-stack"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-y.access"), - name: "access", - value: "a", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ), - ); - expect(Exit.isFailure(result)).toBe(true); - }), +const setup = () => + makeTestExecutor({ plugins: [demoPlugin] as const }).pipe( + Effect.tap((executor) => executor.demo.seed()), ); - it.effect("secrets.list hides connection-owned secrets but surfaces bare ones", () => +describe("connections.create", () => { + it.effect("inline value writes to the default writable provider and produces tools", () => Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.secrets.set( - SetSecretInput.make({ - id: sid("bare-api"), - scope: scpid("test-scope"), - name: "bare API key", - value: "bare", - }), - ); + const executor = yield* setup(); + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); + expect(connection.provider).toBe(ProviderKey.make("memory")); + expect(String(connection.address)).toBe("tools.vercel.org.main"); - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "a", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "r", - }), - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]); - const list = yield* executor.secrets.list(); - const ids = list.map((s) => String(s.id)); - expect(ids).toContain("bare-api"); - expect(ids).not.toContain("conn-1.access"); - expect(ids).not.toContain("conn-1.refresh"); + // The inline value is resolvable via the connection's provider. + const value = yield* executor.demo.resolveValue("org", "main"); + expect(value).toBe("secret-token"); }), ); - it.effect( - "secrets.remove rejects connection-owned secrets with SecretOwnedByConnectionError", - () => - Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "a", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - - const err = yield* executor.secrets - .remove( - RemoveSecretInput.make({ - id: sid("conn-1.access"), - targetScope: scpid("test-scope"), - }), - ) - .pipe(Effect.flip); - expect(Predicate.isTagged(err, "SecretOwnedByConnectionError")).toBe(true); - }), - ); - - it.effect("connections.remove cascades through providers and deletes the core row", () => + it.effect("normalizes free-form names into JS-callable connection identifiers", () => Effect.gen(function* () { - const secretProvider = makeMemoryProvider(); - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(secretProvider), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "access-v1", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "refresh-v1", - }), - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); + const executor = yield* setup(); + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("my-api-key"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", + }); - // Pre-check: backing provider holds the tokens. - expect(yield* secretProvider.get!("conn-1.access", "test-scope")).toBe("access-v1"); - expect(yield* secretProvider.get!("conn-1.refresh", "test-scope")).toBe("refresh-v1"); + expect(String(connection.name)).toBe("myApiKey"); + expect(String(connection.address)).toBe("tools.vercel.org.myApiKey"); - yield* executor.connections.remove( - RemoveConnectionInput.make({ id: cid("conn-1"), targetScope: scpid("test-scope") }), - ); + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.address)).sort()).toEqual([ + "tools.vercel.org.myApiKey.deploy", + "tools.vercel.org.myApiKey.list", + ]); - // Connection row gone. - expect(yield* executor.connections.get("conn-1")).toBeNull(); - // Backing secret values gone from the provider. - expect(yield* secretProvider.get!("conn-1.access", "test-scope")).toBeNull(); - expect(yield* secretProvider.get!("conn-1.refresh", "test-scope")).toBeNull(); + const value = yield* executor.demo.resolveValue("org", "myApiKey"); + expect(value).toBe("secret-token"); }), ); - it.effect("accessToken returns the stored value when not near expiry", () => + it.effect("external `from` references a provider item without writing it", () => Effect.gen(function* () { - const { provider, calls } = makeConnectionProvider({ - key: "spotify", - refresh: () => ({ - accessToken: "should-not-be-used", - }), + const executor = yield* setup(); + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("byo"), + integration: INTEG, + template: TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make("ext-item"), + }, }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "access-fresh", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "refresh-v1", - }), - // Expiry far in the future — no refresh. - expiresAt: Date.now() + 3_600_000, - oauthScope: null, - providerState: null, - }), - ); - - const token = yield* executor.connections.accessToken("conn-1"); - expect(token).toBe("access-fresh"); - expect(calls).toHaveLength(0); + expect(connection.provider).toBe(ProviderKey.make("memory")); + // No value was stored (external reference) — resolveValue returns null. + const value = yield* executor.demo.resolveValue("org", "byo"); + expect(value).toBeNull(); }), ); - it.effect( - "accessToken calls provider.refresh inside the skew window and writes new tokens back", - () => - Effect.gen(function* () { - const secretProvider = makeMemoryProvider(); - const { provider, calls } = makeConnectionProvider({ - key: "spotify", - refresh: (input) => ({ - accessToken: `rotated-${input.refreshToken ?? "none"}`, - refreshToken: "refresh-v2", - expiresAt: Date.now() + 3_600_000, - oauthScope: "user-read user-modify", - providerState: { rotation: "bumped" }, - }), - }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(secretProvider), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: "alice", - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "access-v1", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "refresh-v1", - }), - // Already expired so we're well inside the 60s skew window. - expiresAt: Date.now() - 1_000, - oauthScope: "user-read", - providerState: { rotation: "fresh" }, - }), - ); - - const token = yield* executor.connections.accessToken("conn-1"); - expect(token).toBe("rotated-refresh-v1"); - expect(calls).toHaveLength(1); - expect(calls[0]!.identityLabel).toBe("alice"); - expect(calls[0]!.refreshToken).toBe("refresh-v1"); - expect(calls[0]!.providerState).toEqual({ rotation: "fresh" }); - - // Backing secrets got rewritten at the same ids. - expect(yield* secretProvider.get!("conn-1.access", "test-scope")).toBe( - "rotated-refresh-v1", - ); - expect(yield* secretProvider.get!("conn-1.refresh", "test-scope")).toBe("refresh-v2"); - - const got = yield* executor.connections.get("conn-1"); - expect(got?.providerState).toEqual({ rotation: "bumped" }); - expect(got?.oauthScope).toBe("user-read user-modify"); - }), - ); - - it.effect("accessToken dedupes concurrent refreshes into a single provider call", () => + it.effect("create on an unknown integration fails with IntegrationNotFoundError", () => Effect.gen(function* () { - // A gated refresh provider. Every concurrent caller that lands - // inside the skew window must converge on the single pending - // refresh instead of hitting the token endpoint N times. The - // `entered` Deferred signals that the leader fiber is parked - // inside `refresh`; we only release the `gate` once every - // caller has had a chance to register. - const gate = yield* Deferred.make(); - const entered = yield* Deferred.make(); - const calls: ConnectionRefreshInput[] = []; - let responseCounter = 0; - const provider: ConnectionProvider = { - key: "spotify", - refresh: (input): Effect.Effect => - Effect.gen(function* () { - calls.push(input); - const n = ++responseCounter; - yield* Deferred.succeed(entered, undefined as void); - // Block the leader inside `refresh` until the test - // releases the gate. Any other fiber that concurrently - // calls `accessToken` must observe the in-flight - // Deferred instead of entering this handler. - yield* Deferred.await(gate); - return { - accessToken: `rotated-${n}`, - refreshToken: `refresh-${n}`, - expiresAt: Date.now() + 3_600_000, - oauthScope: "user-read", - providerState: null, - }; - }), - }; - - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "stale", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "refresh-v1", - }), - // Expired so every caller enters the refresh branch. - expiresAt: Date.now() - 1_000, - oauthScope: null, - providerState: null, - }), - ); - - // Kick off the leader first and wait for it to park inside - // `refresh`. Any subsequent caller is guaranteed to see the - // in-flight Deferred the leader just registered. - const leaderFiber = yield* Effect.forkDetach(executor.connections.accessToken("conn-1"), { - startImmediately: true, - }); - yield* Deferred.await(entered); - - const followerFibers = yield* Effect.forEach( - [1, 2, 3, 4], - () => - Effect.forkDetach(executor.connections.accessToken("conn-1"), { - startImmediately: true, - }), - { concurrency: "unbounded" }, - ); - yield* Effect.forEach([1, 2, 3, 4], () => Effect.yieldNow); - - // Every follower is queued on the leader's Deferred. Release - // the gate — the leader resolves, waiters wake up with the - // same token, no extra `refresh` is invoked. - yield* Deferred.succeed(gate, undefined as void); - - const leaderResult = yield* Fiber.join(leaderFiber); - const followerResults = yield* Effect.all( - followerFibers.map((f) => Fiber.await(f)), - { concurrency: "unbounded" }, - ); - expect(leaderResult).toBe("rotated-1"); - for (const result of followerResults) { - expect(Exit.isSuccess(result)).toBe(true); - if (!Exit.isSuccess(result)) continue; - expect(result.value).toBe("rotated-1"); - } - expect(calls).toHaveLength(1); + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("x"), + integration: IntegrationSlug.make("unknown"), + template: TEMPLATE, + value: "v", + }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("IntegrationNotFoundError")(result.failure)).toBe(true); }), ); - it.effect( - "accessToken surfaces ConnectionReauthRequiredError when refresh fails with reauthRequired", - () => - Effect.gen(function* () { - const provider: ConnectionProvider = { - key: "spotify", - refresh: (input) => - Effect.fail( - new ConnectionRefreshError({ - connectionId: input.connectionId, - message: - "OAuth token exchange failed: invalid_grant (stored refresh_token revoked)", - reauthRequired: true, - }), - ), - }; - - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: "alice", - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "stale", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "revoked", - }), - expiresAt: Date.now() - 1_000, - oauthScope: null, - providerState: null, - }), - ); - - const flipped = yield* executor.connections.accessToken("conn-1").pipe(Effect.flip); - expect(Predicate.isTagged(flipped, "ConnectionReauthRequiredError")).toBe(true); - if (!Predicate.isTagged(flipped, "ConnectionReauthRequiredError")) { - return; - } - expect(flipped.provider).toBe("spotify"); - expect(flipped.message).toMatch(/invalid_grant/); - }), - ); - - it.effect("accessToken preserves ConnectionRefreshError for non-reauth failures", () => + // A credentialed connection is "born wired": it must reference at least one + // credential input. An empty binding (an empty `values`/`inputs` map) produces + // a credential with no credential — it persists, produces a full tool catalog, + // and then fails every invocation with `connection_value_missing`. These cases + // must be rejected at create with a typed `InvalidConnectionInputError` (the + // HTTP edge answers 400 with the reason, not an opaque 500). The exception is + // the no-auth template ("none"), where zero inputs and an empty `item_ids` + // map are the canonical shape — covered below. (An empty-STRING value is also + // allowed, and an external `from` that resolves to null is a supported case — + // both covered by their own tests.) + it.effect("rejects an empty `values` map on a credentialed template and persists nothing", () => Effect.gen(function* () { - // Transient failure path — the provider failed but not with a - // terminal RFC 6749 code. The SDK must keep it as-is so - // callers can tell "retry later" from "prompt for sign-in". - const provider: ConnectionProvider = { - key: "spotify", - refresh: (input) => - Effect.fail( - new ConnectionRefreshError({ - connectionId: input.connectionId, - message: "network flake", - }), - ), - }; - - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "stale", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "refresh-v1", - }), - expiresAt: Date.now() - 1_000, - oauthScope: null, - providerState: null, - }), - ); - - const err = yield* executor.connections.accessToken("conn-1").pipe(Effect.flip); - expect(Predicate.isTagged(err, "ConnectionRefreshError")).toBe(true); + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("empty"), + integration: INTEG, + template: TEMPLATE, + values: {}, + }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("InvalidConnectionInputError")(result.failure)).toBe(true); + // No connection row and — critically — no tools were produced. + expect(yield* executor.connections.list()).toEqual([]); + expect(yield* executor.tools.list()).toEqual([]); }), ); - it.effect( - "accessToken fails with ConnectionRefreshNotSupportedError when provider omits refresh", - () => - Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "static-token" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "static-token", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "a", - }), - refreshToken: null, - expiresAt: Date.now() - 1_000, - oauthScope: null, - providerState: null, - }), - ); - - const err = yield* executor.connections.accessToken("conn-1").pipe(Effect.flip); - expect(Predicate.isTagged(err, "ConnectionRefreshNotSupportedError")).toBe(true); - }), - ); - - it.effect("accessToken fails with ConnectionNotFoundError for an unknown id", () => + it.effect("rejects an empty `inputs` map on a credentialed template", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin()] as const, - }), - ); - - const err = yield* executor.connections.accessToken("does-not-exist").pipe(Effect.flip); - expect(Predicate.isTagged(err, "ConnectionNotFoundError")).toBe(true); + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("empty2"), + integration: INTEG, + template: TEMPLATE, + inputs: {}, + }), + ); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("InvalidConnectionInputError")(result.failure)).toBe(true); + expect(yield* executor.connections.list()).toEqual([]); }), ); - it.effect("updateTokens writes new values but does not rotate secret ids", () => + // The no-auth template: public servers need no credential. The UI submits + // `values: {}` for them and the persisted row carries an empty `item_ids` + // map — that is the canonical shape (every migrated no-auth connection in + // prod has it), so it must create cleanly and keep its tools on refresh. + it.effect('creates a no-auth (`template: "none"`) connection from an empty `values` map', () => Effect.gen(function* () { - const secretProvider = makeMemoryProvider(); - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(secretProvider), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: null, - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "v1", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("conn-1.refresh"), - name: "refresh", - value: "r1", - }), - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - - const updated = yield* executor.connections.updateTokens( - UpdateConnectionTokensInput.make({ - id: cid("conn-1"), - accessToken: "v2", - refreshToken: "r2", - expiresAt: 1_700_000_000_000, - oauthScope: "new-scope", - providerState: { rotation: "next" }, - }), - ); - - expect(updated.accessTokenSecretId).toBe(sid("conn-1.access")); - expect(updated.refreshTokenSecretId).toBe(sid("conn-1.refresh")); - expect(updated.expiresAt).toBe(1_700_000_000_000); - expect(updated.oauthScope).toBe("new-scope"); - expect(updated.providerState).toEqual({ rotation: "next" }); + const executor = yield* setup(); + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("public"), + integration: INTEG, + template: AuthTemplateSlug.make("none"), + values: {}, + }); + expect(String(connection.address)).toBe("tools.vercel.org.public"); - expect(yield* secretProvider.get!("conn-1.access", "test-scope")).toBe("v2"); - expect(yield* secretProvider.get!("conn-1.refresh", "test-scope")).toBe("r2"); - }), - ); + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]); - it.effect("updateTokens fails with ConnectionNotFoundError for unknown id", () => - Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - const err = yield* executor.connections - .updateTokens( - UpdateConnectionTokensInput.make({ - id: cid("nope"), - accessToken: "x", - }), - ) - .pipe(Effect.flip); - expect(Predicate.isTagged(err, "ConnectionNotFoundError")).toBe(true); + // Refresh must NOT treat the empty binding as invalid and wipe the tools. + const refreshed = yield* executor.connections.refresh({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("public"), + }); + expect(refreshed.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]); + expect((yield* executor.tools.list()).length).toBe(2); }), ); - it.effect("setIdentityLabel updates the label", () => + it.effect("allows an empty-string value (no-auth integrations bind one)", () => Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "spotify" }); - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), connPlugin(provider)] as const, - }), - ); - - yield* executor.connections.create( - CreateConnectionInput.make({ - id: cid("conn-1"), - scope: scpid("test-scope"), - provider: "spotify", - identityLabel: "original", - accessToken: TokenMaterial.make({ - secretId: sid("conn-1.access"), - name: "access", - value: "a", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - - yield* executor.connections.setIdentityLabel("conn-1", "alice@example"); - const got = yield* executor.connections.get("conn-1"); - expect(got?.identityLabel).toBe("alice@example"); + const executor = yield* setup(); + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("noauth"), + integration: INTEG, + template: TEMPLATE, + value: "", + }); + // The binding exists (non-empty item_ids), so tools are produced; the + // empty value itself is the integration's concern, surfaced at invoke. + expect(String(connection.address)).toBe("tools.vercel.org.noauth"); + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]); }), ); +}); - it.effect("setIdentityLabel fails with ConnectionNotFoundError for unknown id", () => +describe("connections.list / get", () => { + it.effect("lists created connections and filters by integration", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin()] as const, - }), - ); - - const err = yield* executor.connections - .setIdentityLabel("does-not-exist", "x") - .pipe(Effect.flip); - expect(Predicate.isTagged(err, "ConnectionNotFoundError")).toBe(true); + const executor = yield* setup(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("a"), + integration: INTEG, + template: TEMPLATE, + value: "v", + }); + const all = yield* executor.connections.list(); + expect(all.map((c) => String(c.name))).toEqual(["a"]); + const filtered = yield* executor.connections.list({ integration: INTEG }); + expect(filtered.length).toBe(1); + const get = yield* executor.connections.get({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("a"), + }); + expect(get?.name).toBe(ConnectionName.make("a")); }), ); - it.effect("providers() returns every registered connection provider key", () => + it.effect("get returns null for an unknown connection", () => Effect.gen(function* () { - const a = makeConnectionProvider({ key: "prov-a" }); - const b = makeConnectionProvider({ key: "prov-b" }); - const multiPlugin = definePlugin(() => ({ - id: "multi" as const, - storage: () => ({}), - connectionProviders: [a.provider, b.provider], - }))(); - - const executor = yield* createExecutor( - makeTestConfig({ - plugins: [memorySecretsPlugin(), multiPlugin] as const, - }), - ); - - const keys = yield* executor.connections.providers(); - expect([...keys].sort()).toEqual(["oauth2", "prov-a", "prov-b"]); + const executor = yield* setup(); + const get = yield* executor.connections.get({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("missing"), + }); + expect(get).toBeNull(); }), ); }); -// --------------------------------------------------------------------------- -// Multi-scope behaviour — two executors sharing an adapter, same connection -// id registered at different scopes. Reads must innermost-win; removes at -// the inner scope must leave the outer-scope connection intact. -// --------------------------------------------------------------------------- - -const makeLayeredConnExecutors = () => - Effect.gen(function* () { - const { provider } = makeConnectionProvider({ key: "spotify" }); - const plugins = [memorySecretsPlugin(), connPlugin(provider)] as const; - const config = makeTestConfig({ plugins }); - - const outerId = scpid("org"); - const innerId = scpid("user-org:u1:org"); - const outerScope = Scope.make({ - id: outerId, - name: "outer", - createdAt: new Date(), - }); - const innerScope = Scope.make({ - id: innerId, - name: "inner", - createdAt: new Date(), - }); - - const execOuter = yield* createExecutor({ - ...config, - scopes: [outerScope], - plugins, - onElicitation: "accept-all", - }); - const execInner = yield* createExecutor({ - ...config, - scopes: [innerScope, outerScope], - plugins, - onElicitation: "accept-all", - }); - return { execOuter, execInner, outerId, innerId }; - }); - -describe("connections — multi-scope behaviour", () => { - it.effect("get picks the innermost-scope row when the same id exists at two scopes", () => +describe("connections.remove", () => { + it.effect("removes the connection and its tools", () => Effect.gen(function* () { - const { execOuter, execInner, outerId, innerId } = yield* makeLayeredConnExecutors(); - - yield* execOuter.connections.create( - CreateConnectionInput.make({ - id: cid("shared"), - scope: outerId, - provider: "spotify", - identityLabel: "outer", - accessToken: TokenMaterial.make({ - secretId: sid("shared.access.outer"), - name: "access", - value: "outer-access", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - - yield* execInner.connections.create( - CreateConnectionInput.make({ - id: cid("shared"), - scope: innerId, - provider: "spotify", - identityLabel: "inner", - accessToken: TokenMaterial.make({ - secretId: sid("shared.access.inner"), - name: "access", - value: "inner-access", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - - const innerView = yield* execInner.connections.get("shared"); - expect(innerView?.identityLabel).toBe("inner"); - expect(innerView?.scopeId).toBe(innerId); - - const outerView = yield* execOuter.connections.get("shared"); - expect(outerView?.identityLabel).toBe("outer"); - expect(outerView?.scopeId).toBe(outerId); - - // Inner executor's list dedupes — one entry for "shared", the inner one. - const innerList = yield* execInner.connections.list(); - const sharedEntries = innerList.filter((r) => r.id === cid("shared")); - expect(sharedEntries).toHaveLength(1); - expect(sharedEntries[0]!.identityLabel).toBe("inner"); + const executor = yield* setup(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "v", + }); + yield* executor.connections.remove({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("main"), + }); + const connections = yield* executor.connections.list(); + expect(connections).toEqual([]); + const tools = yield* executor.tools.list(); + expect(tools).toEqual([]); }), ); - it.effect("remove at the inner scope does not wipe the outer-scope connection", () => + it.effect("remove on an unknown connection fails with ConnectionNotFoundError", () => Effect.gen(function* () { - const { execOuter, execInner, outerId, innerId } = yield* makeLayeredConnExecutors(); - - yield* execOuter.connections.create( - CreateConnectionInput.make({ - id: cid("shared"), - scope: outerId, - provider: "spotify", - identityLabel: "outer", - accessToken: TokenMaterial.make({ - secretId: sid("shared.access.outer"), - name: "access", - value: "outer-access", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - yield* execInner.connections.create( - CreateConnectionInput.make({ - id: cid("shared"), - scope: innerId, - provider: "spotify", - identityLabel: "inner", - accessToken: TokenMaterial.make({ - secretId: sid("shared.access.inner"), - name: "access", - value: "inner-access", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.remove({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("missing"), }), ); - - yield* execInner.connections.remove( - RemoveConnectionInput.make({ id: cid("shared"), targetScope: innerId }), - ); - - const outerStill = yield* execOuter.connections.get("shared"); - expect(outerStill?.identityLabel).toBe("outer"); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged("ConnectionNotFoundError")(result.failure)).toBe(true); }), ); +}); - it.effect("accessTokenAtScope refreshes with token material from the selected scope", () => +describe("connections.refresh", () => { + it.effect("re-produces the connection's tools", () => Effect.gen(function* () { - const secretProvider = makeMemoryProvider(); - const calls: ConnectionRefreshInput[] = []; - const provider: ConnectionProvider = { - key: "spotify", - refresh: (input) => - Effect.sync(() => { - calls.push(input); - return { - accessToken: `rotated-${input.refreshToken ?? "none"}`, - refreshToken: `${input.refreshToken ?? "none"}-next`, - expiresAt: Date.now() + 3_600_000, - }; - }), - }; - const plugins = [memorySecretsPlugin(secretProvider), connPlugin(provider)] as const; - const config = makeTestConfig({ plugins }); - - const outerId = scpid("org"); - const innerId = scpid("user-org:u1:org"); - const outerScope = Scope.make({ - id: outerId, - name: "outer", - createdAt: new Date(), + const executor = yield* setup(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "v", }); - const innerScope = Scope.make({ - id: innerId, - name: "inner", - createdAt: new Date(), + const tools = yield* executor.connections.refresh({ + owner: "org", + integration: INTEG, + name: ConnectionName.make("main"), }); + expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]); + }), + ); +}); - const execOuter = yield* createExecutor({ - ...config, - scopes: [outerScope], - plugins, - onElicitation: "accept-all", - }); - const execInner = yield* createExecutor({ - ...config, - scopes: [innerScope, outerScope], - plugins, - onElicitation: "accept-all", +describe("execute over a connection", () => { + it.effect("resolves the credential value and hands it to invokeTool", () => + Effect.gen(function* () { + const executor = yield* setup(); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + value: "secret-token", }); - - const sharedConnection = cid("shared"); - yield* execOuter.connections.create( - CreateConnectionInput.make({ - id: sharedConnection, - scope: outerId, - provider: "spotify", - identityLabel: "outer", - accessToken: TokenMaterial.make({ - secretId: sid("shared.access"), - name: "access", - value: "outer-access", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("shared.refresh"), - name: "refresh", - value: "outer-refresh", - }), - expiresAt: Date.now() - 1_000, - oauthScope: null, - providerState: null, - }), - ); - yield* execInner.connections.create( - CreateConnectionInput.make({ - id: sharedConnection, - scope: innerId, - provider: "spotify", - identityLabel: "inner", - accessToken: TokenMaterial.make({ - secretId: sid("shared.access"), - name: "access", - value: "inner-access", - }), - refreshToken: TokenMaterial.make({ - secretId: sid("shared.refresh"), - name: "refresh", - value: "inner-refresh", - }), - expiresAt: Date.now() - 1_000, - oauthScope: null, - providerState: null, - }), - ); - - const token = yield* execInner.connections.accessTokenAtScope("shared", String(innerId)); - - expect(token).toBe("rotated-inner-refresh"); - expect(calls).toHaveLength(1); - expect(calls[0]!.scopeId).toBe(innerId); - expect(calls[0]!.refreshToken).toBe("inner-refresh"); - expect(yield* secretProvider.get!("shared.refresh", String(innerId))).toBe( - "inner-refresh-next", - ); - expect(yield* secretProvider.get!("shared.refresh", String(outerId))).toBe("outer-refresh"); + const out = yield* executor.execute(ToolAddress.make("tools.vercel.org.main.deploy"), {}); + expect(out).toEqual({ ran: "deploy", value: "secret-token" }); }), ); }); diff --git a/packages/core/sdk/src/connections.ts b/packages/core/sdk/src/connections.ts deleted file mode 100644 index cb2c6d42f..000000000 --- a/packages/core/sdk/src/connections.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Data, Effect, Schema } from "effect"; - -import { ConnectionId, ScopeId, SecretId } from "./ids"; - -// --------------------------------------------------------------------------- -// Connections — the product-level "sign-in state" primitive. A Connection -// owns one or more backing `secret` rows (access + refresh tokens) via -// `secret.owned_by_connection_id`; the user sees the Connection, the SDK -// handles every refresh internally. Plugins register a refresh handler -// per provider via `plugin.connectionProviders`, mirroring the shape of -// `plugin.secretProviders` for ordinary secret backends. -// --------------------------------------------------------------------------- - -/** Minimal JSON-object carrier for the plugin-owned `providerState` - * blob. The SDK never inspects its shape; plugins encode/decode their - * own structure. Never sensitive — that's what the secret rows are - * for. */ -export const ConnectionProviderState = Schema.Record(Schema.String, Schema.Unknown); -export type ConnectionProviderState = typeof ConnectionProviderState.Type; - -export const ConnectionIdentityOverride = Schema.Struct({ - displayName: Schema.NullOr(Schema.String), - email: Schema.NullOr(Schema.String), - avatarUrl: Schema.NullOr(Schema.String), -}); -export type ConnectionIdentityOverride = typeof ConnectionIdentityOverride.Type; - -// --------------------------------------------------------------------------- -// ConnectionRef — metadata projection returned from `ctx.connections.list` -// / `executor.connections.list`. Holds token secret ids (so a plugin can -// reference them from its source config) but not token values. -// --------------------------------------------------------------------------- - -export const ConnectionRef = Schema.Struct({ - id: ConnectionId, - scopeId: ScopeId, - provider: Schema.String, - identityLabel: Schema.NullOr(Schema.String), - accessTokenSecretId: SecretId, - refreshTokenSecretId: Schema.NullOr(SecretId), - /** Epoch ms when the access token expires; null if not declared. */ - expiresAt: Schema.NullOr(Schema.Number), - /** OAuth-style scope string as returned by the token endpoint. Named - * `oauthScope` to avoid collision with the executor scope id. */ - oauthScope: Schema.NullOr(Schema.String), - providerState: Schema.NullOr(ConnectionProviderState), - identityOverride: Schema.NullOr(ConnectionIdentityOverride), - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type ConnectionRef = typeof ConnectionRef.Type; - -// --------------------------------------------------------------------------- -// CreateConnectionInput — what a plugin passes to create a fresh -// Connection after a successful OAuth exchange. The SDK writes the -// backing secret rows and the connection row in one transaction, and -// stamps `owned_by_connection_id` so `ctx.secrets.list` automatically -// hides them from the bare-secrets UI. -// -// `provider` must match a registered `ConnectionProvider.key`. The SDK -// validates this at create time so a typo surfaces immediately instead -// of the first time a refresh is attempted. -// --------------------------------------------------------------------------- - -export const TokenMaterial = Schema.Struct({ - /** Target secret id. Plugins typically derive this from the source id - * + a stable suffix (e.g. `${sourceId}.access_token`). */ - secretId: SecretId, - /** Display name stamped on the secret row. Only visible to code — the - * Connections UI hides connection-owned secrets. */ - name: Schema.String, - value: Schema.String, -}); -export type TokenMaterial = typeof TokenMaterial.Type; - -export const CreateConnectionInput = Schema.Struct({ - id: ConnectionId, - /** Executor scope id that will own this connection + its backing - * secrets. This is the sharing boundary: a user scope is personal, - * an org/workspace scope is shared with descendants. */ - scope: ScopeId, - provider: Schema.String, - identityLabel: Schema.NullOr(Schema.String), - accessToken: TokenMaterial, - refreshToken: Schema.NullOr(TokenMaterial), - expiresAt: Schema.NullOr(Schema.Number), - /** OAuth-style scope string. Distinct from the executor scope above. */ - oauthScope: Schema.NullOr(Schema.String), - providerState: Schema.NullOr(ConnectionProviderState), -}); -export type CreateConnectionInput = typeof CreateConnectionInput.Type; - -// --------------------------------------------------------------------------- -// ConnectionRefreshError — typed error surface for refresh handlers. -// Plugins either return a fresh token envelope or fail with this error; -// the SDK rethrows it from `ctx.connections.accessToken` callers. -// --------------------------------------------------------------------------- - -export class ConnectionRefreshError extends Data.TaggedError("ConnectionRefreshError")<{ - readonly connectionId: ConnectionId; - readonly message: string; - /** - * Set by providers when the refresh failed in a way that the stored - * refresh token cannot recover from (RFC 6749 §5.2 `invalid_grant` - * — the AS has revoked the grant, the user changed their password, - * the refresh token rotated out from under us, ...). The SDK - * translates this into a `ConnectionReauthRequiredError` so callers - * can prompt the user to sign in again instead of silently retrying. - */ - readonly reauthRequired?: boolean; - readonly cause?: unknown; -}> {} - -// --------------------------------------------------------------------------- -// ConnectionRefreshInput — what the SDK hands to a provider's `refresh` -// callback. Includes the current refresh-token value (already resolved -// from the secret row) and the opaque providerState blob so handlers -// don't need to hit secrets themselves. -// --------------------------------------------------------------------------- - -export interface ConnectionRefreshInput { - readonly connectionId: ConnectionId; - readonly scopeId: ScopeId; - readonly identityLabel: string | null; - /** Resolved refresh token value, or null if the connection has none. */ - readonly refreshToken: string | null; - /** Plugin-owned blob persisted at create / previous refresh. */ - readonly providerState: ConnectionProviderState | null; - /** OAuth scope string from the last token issuance. */ - readonly oauthScope: string | null; -} - -// --------------------------------------------------------------------------- -// ConnectionRefreshResult — what a provider's `refresh` callback returns -// on success. The SDK writes the new token values through the secret -// providers, updates `expires_at` / `scope` / `provider_state` on the -// connection row, and returns the fresh access token to the caller. -// -// `refreshToken` is optional: if the AS rotates refresh tokens it's -// present, if the AS issues long-lived refresh tokens it's absent. The -// SDK updates the refresh secret only when a new value is supplied. -// --------------------------------------------------------------------------- - -export interface ConnectionRefreshResult { - readonly accessToken: string; - readonly refreshToken?: string | null; - readonly expiresAt?: number | null; - readonly oauthScope?: string | null; - readonly providerState?: ConnectionProviderState | null; -} - -// --------------------------------------------------------------------------- -// ConnectionProvider — plugin contribution. Registered via -// `plugin.connectionProviders`. One per refresh strategy (oauth2 -// authorization-code, oauth2 client-credentials, per-provider custom, -// etc). Keyed by `key`; the connection row's `provider` column -// references this key. -// -// Omitting `refresh` means "tokens minted by this provider never -// refresh" — accessToken(id) just returns the stored value. Useful for -// long-lived API tokens wrapped as connections for UX consistency. -// --------------------------------------------------------------------------- - -export interface ConnectionProvider { - readonly key: string; - readonly refresh?: ( - input: ConnectionRefreshInput, - ) => Effect.Effect; -} - -// --------------------------------------------------------------------------- -// UpdateConnectionTokensInput — for flows that re-exchange tokens out -// of band (e.g. an OAuth re-auth where the user signs in again). The -// SDK overwrites the backing secrets and updates the connection row in -// one transaction. -// --------------------------------------------------------------------------- - -export const UpdateConnectionTokensInput = Schema.Struct({ - id: ConnectionId, - accessToken: Schema.String, - refreshToken: Schema.optional(Schema.NullOr(Schema.String)), - expiresAt: Schema.optional(Schema.NullOr(Schema.Number)), - oauthScope: Schema.optional(Schema.NullOr(Schema.String)), - providerState: Schema.optional(Schema.NullOr(ConnectionProviderState)), - identityLabel: Schema.optional(Schema.NullOr(Schema.String)), -}); -export type UpdateConnectionTokensInput = typeof UpdateConnectionTokensInput.Type; - -export const UpdateConnectionIdentityInput = Schema.Struct({ - id: ConnectionId, - targetScope: ScopeId, - identityOverride: Schema.NullOr(ConnectionIdentityOverride), -}); -export type UpdateConnectionIdentityInput = typeof UpdateConnectionIdentityInput.Type; - -export const RemoveConnectionInput = Schema.Struct({ - id: ConnectionId, - /** Scope id whose connection row and owned token secrets should be removed. */ - targetScope: ScopeId, -}); -export type RemoveConnectionInput = typeof RemoveConnectionInput.Type; diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index f3808cd7e..401bac5a4 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -1,19 +1,27 @@ import { column, idColumn, table, type AnyColumn, type AnyTable } from "fumadb/schema"; -import type { FumaRow } from "./fuma-runtime"; +import type { Condition, ConditionBuilder } from "fumadb/query"; + +import { StorageError, type FumaRow } from "./fuma-runtime"; import { - assertExecutorScopeAllowed, - assertExecutorScopeTargetValue, - executorScopePolicyName, + assertOwnerPatch, + assertOwnerWritable, + executorOwnerPolicyName, + executorTenantPolicyName, executorUnscopedPolicyName, - executorScopeIds, - requireExecutorScopeTarget, - type ExecutorScopePolicyContext, -} from "./scope-policy"; + ownerVisibilityCondition, + type ExecutorOwnerPolicyContext, +} from "./owner-policy"; type UserColumns = Record; +type AnyConditionBuilder = ConditionBuilder>; +// Column helpers. Index-participating columns use `varchar(255)` so unique +// indexes stay portable (TEXT can't be indexed without a prefix length on +// MySQL); free-form columns use `string` (TEXT). export const textColumn = (name: string) => column(name, "string"); export const nullableTextColumn = (name: string) => column(name, "string").nullable(); +export const keyColumn = (name: string) => column(name, "varchar(255)"); +export const nullableKeyColumn = (name: string) => column(name, "varchar(255)").nullable(); export const boolColumn = (name: string, defaultValue: boolean) => column(name, "bool").defaultTo(defaultValue); export const bigintColumn = (name: string) => column(name, "bigint"); @@ -22,6 +30,14 @@ export const jsonColumn = (name: string) => column(name, "json"); export const nullableJsonColumn = (name: string) => column(name, "json").nullable(); export const dateColumn = (name: string) => column(name, "timestamp"); +// The policy callback hands us a `ConditionBuilder` typed to the specific table's +// columns; it isn't assignable to the generic `Record` builder +// (column-name positions are contravariant), so accept it loosely and re-narrow. +const ownerVisibility = (builder: unknown, context: ExecutorOwnerPolicyContext) => + ownerVisibilityCondition(builder as AnyConditionBuilder, context) as Condition | boolean; + +/** A truly global table (the blob store). Isolation is carried in the row's + * `namespace` (which encodes the owner partition + plugin id), not a policy. */ const unscopedExecutorTable = ( name: string, columns: TColumns, @@ -29,190 +45,237 @@ const unscopedExecutorTable = ( const out = table(name, { ...columns, row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), - id: column("id", "varchar(255)"), + id: keyColumn("id"), }); out.unique(`${name}_id_uidx`, ["id"]); - return out.policy({ - name: executorUnscopedPolicyName, - }); + return out.policy({ name: executorUnscopedPolicyName }); }; -const scopedExecutorTableBase = ( +/** A tenant-shared table (catalog / blobs) — partitioned only by `tenant`. */ +const tenantExecutorTable = ( name: string, columns: TColumns, + uniqueKey: readonly string[], ) => { const out = table(name, { ...columns, row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), - id: column("id", "varchar(255)"), - scope_id: column("scope_id", "varchar(255)"), + tenant: keyColumn("tenant"), + }); + out.unique(`${name}_uidx`, [...uniqueKey]); + return out.policy({ + name: executorTenantPolicyName, + onRead: ({ builder, context }) => builder("tenant", "=", context.tenant), + onCreate: ({ values, context }) => { + if (values.tenant !== context.tenant) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: FumaDB table policy callbacks are promise callbacks, not Effect effects + throw new StorageError({ + message: `Storage write on table "${name}" is outside the executor tenant.`, + cause: undefined, + }); + } + }, + onUpdate: ({ builder, context }) => builder("tenant", "=", context.tenant), + onDelete: ({ builder, context }) => builder("tenant", "=", context.tenant), }); - out.unique(`${name}_scope_id_id_uidx`, ["scope_id", "id"]); - return out; }; -export const scopedExecutorTable = ( +/** An owner-scoped table — partitioned by `(tenant, owner, subject)`, guarded by + * the executor owner policy. `uniqueKey` must include those three columns. */ +const ownedExecutorTable = ( name: string, columns: TColumns, + uniqueKey: readonly string[], ) => { - const out = scopedExecutorTableBase(name, columns); - return out.policy({ - name: executorScopePolicyName, - onRead: ({ builder, context }) => - builder("scope_id", "in", executorScopeIds(name, "read", context)), - onCreate: ({ values, context }) => - assertExecutorScopeAllowed(name, "write", values.scope_id, context), - onUpdate: ({ builder, set, create, where, context }) => { - const target = requireExecutorScopeTarget(name, "write", where, context); - if (set.scope_id !== undefined) { - assertExecutorScopeTargetValue(name, "write", set.scope_id, target, context); - } - if (create?.scope_id !== undefined) { - assertExecutorScopeTargetValue(name, "write", create.scope_id, target, context); - } - return builder("scope_id", "=", target.value); - }, - onDelete: ({ builder, where, context }) => { - const target = requireExecutorScopeTarget(name, "delete", where, context); - return builder("scope_id", "=", target.value); + const out = table(name, { + ...columns, + row_id: idColumn("row_id", "varchar(255)").defaultTo$("auto"), + tenant: keyColumn("tenant"), + owner: keyColumn("owner"), + subject: keyColumn("subject"), + }); + out.unique(`${name}_uidx`, [...uniqueKey]); + return out.policy({ + name: executorOwnerPolicyName, + onRead: ({ builder, context }) => ownerVisibility(builder, context), + onCreate: ({ values, context }) => assertOwnerWritable(name, values, context), + onUpdate: ({ builder, set, create, context }) => { + assertOwnerPatch(name, set, context); + assertOwnerPatch(name, create, context); + return ownerVisibility(builder, context); }, + onDelete: ({ builder, context }) => ownerVisibility(builder, context), }); }; const defineTables = >(tables: TTables): TTables => tables; -export const credentialBindingKinds = ["text", "secret", "connection"] as const; +export const coreTables = defineTables({ + // The catalog — tenant-shared integration definitions. `config` is the owning + // plugin's opaque blob (openapi auth templates + spec; mcp url). Core never + // parses it. + integration: tenantExecutorTable( + "integration", + { + slug: keyColumn("slug"), + plugin_id: textColumn("plugin_id"), + description: textColumn("description"), + config: nullableJsonColumn("config"), + can_remove: boolColumn("can_remove", true), + can_refresh: boolColumn("can_refresh", false), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "slug"], + ), -const credentialBindingTable = (() => { - const out = scopedExecutorTableBase("credential_binding", { - plugin_id: textColumn("plugin_id"), - source_id: textColumn("source_id"), - source_scope_id: textColumn("source_scope_id"), - slot_key: textColumn("slot_key"), - kind: textColumn("kind"), - text_value: nullableTextColumn("text_value"), - secret_id: nullableTextColumn("secret_id"), - secret_scope_id: nullableTextColumn("secret_scope_id"), - connection_id: nullableTextColumn("connection_id"), - created_at: dateColumn("created_at"), - updated_at: dateColumn("updated_at"), - }); + // THE saved credential, one per (owner, integration, name). Resolves each named + // input via `provider` + the `item_ids` map (variable → provider item id). A + // single-secret connection is `{ "token": }`; an apiKey method with two + // distinct inputs (e.g. Datadog) carries one entry per variable. All of a + // connection's inputs share the one `provider`. OAuth fields null for static. + connection: ownedExecutorTable( + "connection", + { + integration: keyColumn("integration"), + name: keyColumn("name"), + template: textColumn("template"), + provider: textColumn("provider"), + item_ids: jsonColumn("item_ids"), + identity_label: nullableTextColumn("identity_label"), + oauth_client: nullableTextColumn("oauth_client"), + // The OWNER of `oauth_client` (a Personal connection may be minted through + // a shared Workspace app), set together with `oauth_client`; null for + // static creds. Stored so every deref (refresh/complete/reconnect) reads it + // verbatim instead of re-deriving it via a sharing rule. + oauth_client_owner: nullableTextColumn("oauth_client_owner"), + refresh_item_id: nullableTextColumn("refresh_item_id"), + expires_at: nullableBigintColumn("expires_at"), + oauth_scope: nullableTextColumn("oauth_scope"), + provider_state: nullableJsonColumn("provider_state"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "integration", "name"], + ), - return out.policy({ - name: executorScopePolicyName, - onRead: ({ builder, context }) => - builder("scope_id", "in", executorScopeIds("credential_binding", "read", context)), - onCreate: ({ values, context }) => - assertExecutorScopeAllowed("credential_binding", "write", values.scope_id, context), - onUpdate: ({ builder, set, create, where, context }) => { - const target = requireExecutorScopeTarget("credential_binding", "write", where, context); - if (set.scope_id !== undefined) { - assertExecutorScopeTargetValue( - "credential_binding", - "write", - set.scope_id, - target, - context, - ); - } - if (create?.scope_id !== undefined) { - assertExecutorScopeTargetValue( - "credential_binding", - "write", - create.scope_id, - target, - context, - ); - } - return builder("scope_id", "=", target.value); + // A registered OAuth app — owner-scoped (shared org app or a member's BYO app). + // A registered OAuth app — pure app identity (id/secret + endpoints). It carries + // NO scopes: what to request is the integration's concern, so the same app can + // back any integration. The granted scope is recorded per-connection + // (`connection.oauth_scope`). + oauth_client: ownedExecutorTable( + "oauth_client", + { + slug: keyColumn("slug"), + authorization_url: textColumn("authorization_url"), + token_url: textColumn("token_url"), + grant: textColumn("grant"), + client_id: textColumn("client_id"), + // The client secret is NOT stored inline — it's a provider `item_id` that + // resolves to the value via the default writable credential provider + // (WorkOS Vault on cloud, the local store on desktop). Null for public / + // PKCE clients (no secret). Keeps secrets out of plaintext columns. + client_secret_item_id: nullableTextColumn("client_secret_item_id"), + // RFC 8707 Resource Indicator (MCP). Sent on the refresh request so the + // re-minted access token stays bound to the same resource. Null when the + // provider doesn't use resource indicators. + resource: nullableTextColumn("resource"), + // Where this oauth_client came from. Null in old databases is treated as + // "manual" by the service layer. + origin_kind: nullableTextColumn("origin_kind"), + origin_integration: nullableTextColumn("origin_integration"), + created_at: dateColumn("created_at"), }, - onDelete: ({ builder, where, context }) => { - const target = requireExecutorScopeTarget("credential_binding", "delete", where, context, [ - "scope_id", - "source_scope_id", - ]); - return builder(target.column, "=", target.value); + ["tenant", "owner", "subject", "slug"], + ), + + // In-flight OAuth authorization-code flow, keyed by the minted `state`. + oauth_session: ownedExecutorTable( + "oauth_session", + { + state: keyColumn("state"), + client_slug: textColumn("client_slug"), + integration: textColumn("integration"), + name: textColumn("name"), + template: textColumn("template"), + redirect_url: textColumn("redirect_url"), + pkce_verifier: nullableTextColumn("pkce_verifier"), + identity_label: nullableTextColumn("identity_label"), + payload: jsonColumn("payload"), + expires_at: bigintColumn("expires_at"), + created_at: dateColumn("created_at"), }, - }); -})(); + ["tenant", "state"], + ), -export const coreTables = defineTables({ - source: scopedExecutorTable("source", { - plugin_id: textColumn("plugin_id"), - kind: textColumn("kind"), - name: textColumn("name"), - url: nullableTextColumn("url"), - can_remove: boolColumn("can_remove", true), - can_refresh: boolColumn("can_refresh", false), - can_edit: boolColumn("can_edit", false), - created_at: dateColumn("created_at"), - updated_at: dateColumn("updated_at"), - }), - tool: scopedExecutorTable("tool", { - source_id: textColumn("source_id"), - plugin_id: textColumn("plugin_id"), - name: textColumn("name"), - description: textColumn("description"), - input_schema: nullableJsonColumn("input_schema"), - output_schema: nullableJsonColumn("output_schema"), - created_at: dateColumn("created_at"), - updated_at: dateColumn("updated_at"), - }), - definition: scopedExecutorTable("definition", { - source_id: textColumn("source_id"), - plugin_id: textColumn("plugin_id"), - name: textColumn("name"), - schema: jsonColumn("schema"), - created_at: dateColumn("created_at"), - }), - secret: scopedExecutorTable("secret", { - name: textColumn("name"), - provider: textColumn("provider"), - owned_by_connection_id: nullableTextColumn("owned_by_connection_id"), - created_at: dateColumn("created_at"), - }), - connection: scopedExecutorTable("connection", { - provider: textColumn("provider"), - identity_label: nullableTextColumn("identity_label"), - access_token_secret_id: textColumn("access_token_secret_id"), - refresh_token_secret_id: nullableTextColumn("refresh_token_secret_id"), - expires_at: nullableBigintColumn("expires_at"), - scope: nullableTextColumn("scope"), - provider_state: nullableJsonColumn("provider_state"), - identity_override: nullableJsonColumn("identity_override"), - created_at: dateColumn("created_at"), - updated_at: dateColumn("updated_at"), - }), - oauth2_session: scopedExecutorTable("oauth2_session", { - plugin_id: textColumn("plugin_id"), - strategy: textColumn("strategy"), - connection_id: textColumn("connection_id"), - token_scope: textColumn("token_scope"), - redirect_url: textColumn("redirect_url"), - payload: jsonColumn("payload"), - expires_at: bigintColumn("expires_at"), - created_at: dateColumn("created_at"), - }), - credential_binding: credentialBindingTable, - plugin_storage: scopedExecutorTable("plugin_storage", { - plugin_id: textColumn("plugin_id"), - collection: textColumn("collection"), - key: textColumn("key"), - data: jsonColumn("data"), - created_at: dateColumn("created_at"), - updated_at: dateColumn("updated_at"), - }), - tool_policy: scopedExecutorTable("tool_policy", { - pattern: textColumn("pattern"), - action: textColumn("action"), - position: textColumn("position"), - created_at: dateColumn("created_at"), - updated_at: dateColumn("updated_at"), - }), + // Persisted, per-connection tools (option C). Address is derived from + // (integration, owner, connection, name). + tool: ownedExecutorTable( + "tool", + { + integration: keyColumn("integration"), + connection: keyColumn("connection"), + plugin_id: textColumn("plugin_id"), + name: keyColumn("name"), + description: textColumn("description"), + input_schema: nullableJsonColumn("input_schema"), + output_schema: nullableJsonColumn("output_schema"), + annotations: nullableJsonColumn("annotations"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "integration", "connection", "name"], + ), + + // Shared JSON-schema $defs, per-connection (mirrors `tool`). + definition: ownedExecutorTable( + "definition", + { + integration: keyColumn("integration"), + connection: keyColumn("connection"), + plugin_id: textColumn("plugin_id"), + name: keyColumn("name"), + schema: jsonColumn("schema"), + created_at: dateColumn("created_at"), + }, + ["tenant", "owner", "subject", "integration", "connection", "name"], + ), + + // User-authored tool policies (approve / require_approval / block). + tool_policy: ownedExecutorTable( + "tool_policy", + { + id: keyColumn("id"), + pattern: textColumn("pattern"), + action: textColumn("action"), + position: textColumn("position"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "id"], + ), + + // Host-owned plugin storage (shared `plugin_storage` table, owner-scoped). + plugin_storage: ownedExecutorTable( + "plugin_storage", + { + plugin_id: keyColumn("plugin_id"), + collection: keyColumn("collection"), + key: keyColumn("key"), + data: jsonColumn("data"), + created_at: dateColumn("created_at"), + updated_at: dateColumn("updated_at"), + }, + ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + ), + + // Opaque blob store, global. Isolation is carried in `namespace` (which + // encodes the owner partition + plugin id), so this table is unscoped. blob: unscopedExecutorTable("blob", { - namespace: textColumn("namespace"), - key: textColumn("key"), + namespace: keyColumn("namespace"), + key: keyColumn("key"), value: textColumn("value"), }), }); @@ -220,37 +283,15 @@ export const coreTables = defineTables({ export const coreSchema = coreTables; export type CoreSchema = typeof coreTables; -export type SourceRow = FumaRow; +export type IntegrationRow = FumaRow; +export type ConnectionRow = FumaRow; +export type OAuthClientRow = FumaRow; +export type OAuthSessionRow = FumaRow; export type ToolRow = FumaRow; export type DefinitionRow = FumaRow; -export type SecretRow = FumaRow; -export type ConnectionRow = FumaRow; -export type PluginStorageRow = FumaRow; - -type CredentialBindingRowBase = Omit< - FumaRow, - "kind" | "text_value" | "secret_id" | "secret_scope_id" | "connection_id" ->; - -export type CredentialBindingRow = CredentialBindingRowBase & - ( - | { - kind: "text"; - text_value: string; - } - | { - kind: "secret"; - secret_id: string; - secret_scope_id?: string | null; - } - | { - kind: "connection"; - connection_id: string; - } - ) & - Record; - export type ToolPolicyRow = FumaRow; +export type PluginStorageRow = FumaRow; +export type BlobRow = FumaRow; export type ToolPolicyAction = "approve" | "require_approval" | "block"; @@ -262,34 +303,3 @@ export const TOOL_POLICY_ACTIONS = [ export const isToolPolicyAction = (value: unknown): value is ToolPolicyAction => typeof value === "string" && (TOOL_POLICY_ACTIONS as readonly string[]).includes(value); - -export interface ToolAnnotations { - readonly requiresApproval?: boolean; - readonly approvalDescription?: string; - readonly mayElicit?: boolean; -} - -export interface SourceInputTool { - readonly name: string; - readonly description: string; - readonly inputSchema?: unknown; - readonly outputSchema?: unknown; -} - -export interface SourceInput { - readonly id: string; - readonly scope: string; - readonly kind: string; - readonly name: string; - readonly url?: string; - readonly canRemove?: boolean; - readonly canRefresh?: boolean; - readonly canEdit?: boolean; - readonly tools: readonly SourceInputTool[]; -} - -export interface DefinitionsInput { - readonly sourceId: string; - readonly scope: string; - readonly definitions: Record; -} diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 6f7141dfd..921402031 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -2,22 +2,27 @@ // core-tools plugin // // Built-in plugin that contributes agent-facing static tools for configuring -// executor-level primitives. The important boundary: sensitive values never -// travel through tool arguments. Agents create secret placeholders and OAuth -// sessions, then hand the returned browser URL to the user. +// executor-level primitives over the v2 surface. Agent-facing connection setup +// should hand users to the web UI for pasted credentials; low-level creation +// through this plugin only accepts provider refs. // --------------------------------------------------------------------------- -import { Data, Effect, Schema } from "effect"; - -import { ConnectionRef } from "./connections"; -import { CredentialBindingRef, CredentialBindingValue } from "./credential-bindings"; -import { ConnectionId, ScopeId, SecretId } from "./ids"; -import { OAuthStrategy as OAuthStrategySchema } from "./oauth"; +import { Effect, Schema } from "effect"; + +import type { Connection, ConnectionInputOrigin, CreateConnectionInput } from "./connection"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + OAuthState, + ProviderItemId, + ProviderKey, + type Owner, +} from "./ids"; import { definePlugin, tool, type StaticToolSchema } from "./plugin"; import { ToolPolicyActionSchema } from "./policies"; -import { ToolResult } from "./tool-result"; -import { SourceDetectionResult } from "./types"; -import { Usage } from "./usages"; +import type { Tool } from "./tool"; const schemaToStandard = (schema: Schema.Decoder): StaticToolSchema => Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< @@ -25,476 +30,358 @@ const schemaToStandard = (schema: Schema.Decoder): StaticToolSchema< I >; -const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); +const OwnerSchema = Schema.Literals(["org", "user"]); +const OAuthGrantSchema = Schema.Literals(["authorization_code", "client_credentials"]); + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- -const ScopeName = Schema.String; +const IntegrationOutput = Schema.Struct({ + slug: Schema.String, + description: Schema.String, + kind: Schema.String, + canRemove: Schema.Boolean, + canRefresh: Schema.Boolean, +}); -const ScopesListOutput = Schema.Struct({ - scopes: Schema.Array( +const IntegrationsListOutput = Schema.Struct({ + integrations: Schema.Array(IntegrationOutput), +}); + +const DetectInput = Schema.Struct({ url: Schema.String }); +const DetectOutput = Schema.Struct({ + results: Schema.Array( Schema.Struct({ - id: Schema.String, + kind: Schema.String, + confidence: Schema.Literals(["high", "medium", "low"]), + endpoint: Schema.String, name: Schema.String, + slug: Schema.String, }), ), }); -const SecretRefOutput = Schema.Struct({ - id: Schema.String, - scopeId: Schema.String, +const ConnectionOutput = Schema.Struct({ + owner: OwnerSchema, name: Schema.String, + integration: Schema.String, + template: Schema.String, provider: Schema.String, + address: Schema.String, + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), + expiresAt: Schema.NullOr(Schema.Number), + oauthClient: Schema.NullOr(Schema.String), + oauthClientOwner: Schema.NullOr(OwnerSchema), + oauthScope: Schema.NullOr(Schema.String), }); -const SecretsListOutput = Schema.Struct({ - secrets: Schema.Array(SecretRefOutput), +const ConnectionsListInput = Schema.Struct({ + integration: Schema.optional(Schema.String), + owner: Schema.optional(OwnerSchema), }); - -const SecretsCreateInput = Schema.Struct({ - name: Schema.String, - scope: Schema.optional(ScopeName), - provider: Schema.optional(Schema.String), +const ConnectionsListOutput = Schema.Struct({ + connections: Schema.Array(ConnectionOutput), }); -const SecretsCreateOutput = Schema.Struct({ - id: Schema.String, +const ConnectionCreateHandoffInput = Schema.Struct({ + integration: Schema.String, + owner: Schema.optional(OwnerSchema), + template: Schema.optional(Schema.String), + label: Schema.optional(Schema.String), +}); +const ConnectionCreateHandoffOutput = Schema.Struct({ url: Schema.String, instructions: Schema.String, }); -const SecretPointerInput = Schema.Struct({ - id: Schema.String, -}); - -const SecretScopedPointerInput = Schema.Struct({ - id: Schema.String, - targetScope: Schema.String, -}); - -const SecretStatusOutput = Schema.Struct({ +const ConnectionFromInput = Schema.Struct({ + provider: Schema.String, id: Schema.String, - status: Schema.Literals(["resolved", "missing"]), }); - -const SecretUsagesOutput = Schema.Struct({ - usages: Schema.Array(Usage), -}); - -const ProvidersOutput = Schema.Struct({ - providers: Schema.Array(Schema.String), -}); - -const RemovedOutput = Schema.Struct({ - removed: Schema.Boolean, -}); - -const RefreshedOutput = Schema.Struct({ - refreshed: Schema.Boolean, -}); - -const SourceOutput = Schema.Struct({ - id: Schema.String, - scopeId: Schema.optional(Schema.String), - kind: Schema.String, +const ConnectionInputOriginInput = Schema.Struct({ from: ConnectionFromInput }); +const ConnectionCreateInput = Schema.Struct({ + owner: OwnerSchema, name: Schema.String, - url: Schema.optional(Schema.String), - pluginId: Schema.String, - canRemove: Schema.Boolean, - canRefresh: Schema.Boolean, - canEdit: Schema.Boolean, - runtime: Schema.Boolean, -}); - -const SourcesListOutput = Schema.Struct({ - sources: Schema.Array(SourceOutput), -}); - -const SourcesDetectInput = Schema.Struct({ - url: Schema.String, -}); - -const SourcesDetectOutput = Schema.Struct({ - results: Schema.Array(SourceDetectionResult), -}); - -const SourcePresetOutput = Schema.Struct({ - pluginId: Schema.String, - id: Schema.String, + integration: Schema.String, + template: Schema.String, + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), + from: Schema.optional(ConnectionFromInput), + inputs: Schema.optional(Schema.Record(Schema.String, ConnectionInputOriginInput)), +}).check( + Schema.makeFilter((payload) => { + const originCount = + (payload.from === undefined ? 0 : 1) + (payload.inputs === undefined ? 0 : 1); + if (originCount !== 1) return "Expected exactly one provider credential origin"; + if (payload.inputs !== undefined && Object.keys(payload.inputs).length === 0) { + return "Expected at least one provider credential input"; + } + return undefined; + }), +); +const ConnectionRefInput = Schema.Struct({ + owner: OwnerSchema, name: Schema.String, - summary: Schema.String, - url: Schema.optional(Schema.String), - endpoint: Schema.optional(Schema.String), - icon: Schema.optional(Schema.String), - featured: Schema.optional(Schema.Boolean), - transport: Schema.optional(Schema.Literals(["remote", "stdio"])), - command: Schema.optional(Schema.String), - args: Schema.optional(Schema.Array(Schema.String)), - env: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}); - -const SourcesPresetsInput = Schema.Struct({ - query: Schema.optional(Schema.String), - pluginId: Schema.optional(Schema.String), - featuredOnly: Schema.optional(Schema.Boolean), - limit: Schema.optional(Schema.Number), -}); - -const SourcesPresetsOutput = Schema.Struct({ - presets: Schema.Array(SourcePresetOutput), -}); - -const SourcePointer = Schema.Struct({ - id: Schema.String, - scope: Schema.String, -}); - -const SourcesConfigureInput = Schema.Struct({ - source: SourcePointer, - scope: Schema.String, - type: Schema.optional(Schema.String), - config: Schema.Unknown, + integration: Schema.String, }); -const SourcesConfigureOutput = Schema.Struct({ - result: Schema.Unknown, -}); - -const SourceLifecycleInput = Schema.Struct({ - id: Schema.String, - targetScope: Schema.String, -}); - -const SourceBindingsListInput = Schema.Struct({ - source: SourcePointer, -}); - -const SourceBindingsResolveInput = Schema.Struct({ - source: SourcePointer, - slotKey: Schema.String, -}); - -const SourceBindingsSetInput = Schema.Struct({ - scope: Schema.String, - source: SourcePointer, - slotKey: Schema.String, - value: CredentialBindingValue, -}); - -const SourceBindingsRemoveInput = Schema.Struct({ - scope: Schema.String, - source: SourcePointer, - slotKey: Schema.String, -}); - -const SourceBindingsListOutput = Schema.Struct({ - bindings: Schema.Array(CredentialBindingRef), -}); - -const SourceBindingsResolveOutput = Schema.Struct({ - binding: Schema.NullOr(CredentialBindingRef), +const ToolOutput = Schema.Struct({ + address: Schema.String, + owner: OwnerSchema, + integration: Schema.String, + connection: Schema.String, + name: Schema.String, + pluginId: Schema.String, + description: Schema.String, }); - -const SourceBindingsSetOutput = Schema.Struct({ - binding: CredentialBindingRef, +const ConnectionsRefreshOutput = Schema.Struct({ + tools: Schema.Array(ToolOutput), }); -const ConnectionsListOutput = Schema.Struct({ - connections: Schema.Array(ConnectionRef), -}); - -const ConnectionPointerInput = Schema.Struct({ - id: Schema.String, -}); +const RemovedOutput = Schema.Struct({ removed: Schema.Boolean }); +const CancelledOutput = Schema.Struct({ cancelled: Schema.Boolean }); -const ConnectionScopedPointerInput = Schema.Struct({ - id: Schema.String, - targetScope: Schema.String, +const ProvidersOutput = Schema.Struct({ + providers: Schema.Array(Schema.String), }); -const ConnectionUsagesOutput = Schema.Struct({ - usages: Schema.Array(Usage), +const ProviderItemsInput = Schema.Struct({ provider: Schema.String }); +const ProviderItemsOutput = Schema.Struct({ + items: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })), }); const PolicyOutput = Schema.Struct({ id: Schema.String, - scopeId: Schema.String, + owner: OwnerSchema, pattern: Schema.String, - action: ToolPolicyActionSchema, + action: Schema.String, position: Schema.String, - createdAt: Schema.Number, - updatedAt: Schema.Number, }); - const PoliciesListOutput = Schema.Struct({ policies: Schema.Array(PolicyOutput), }); const PolicyCreateInput = Schema.Struct({ - targetScope: Schema.String, + owner: OwnerSchema, pattern: Schema.String, action: ToolPolicyActionSchema, - position: Schema.optional(Schema.String), }); - const PolicyUpdateInput = Schema.Struct({ id: Schema.String, - targetScope: Schema.String, + owner: OwnerSchema, pattern: Schema.optional(Schema.String), action: Schema.optional(ToolPolicyActionSchema), - position: Schema.optional(Schema.String), }); - const PolicyRemoveInput = Schema.Struct({ id: Schema.String, - targetScope: Schema.String, -}); - -const PolicyMutationOutput = Schema.Struct({ - policy: PolicyOutput, + owner: OwnerSchema, +}); + +const OAuthClientOutput = Schema.Struct({ + owner: OwnerSchema, + slug: Schema.String, + grant: OAuthGrantSchema, + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), + clientId: Schema.String, + origin: Schema.Union([ + Schema.Struct({ kind: Schema.Literal("manual") }), + Schema.Struct({ + kind: Schema.Literal("dynamic_client_registration"), + integration: Schema.optional(Schema.NullOr(Schema.String)), + }), + ]), +}); +const OAuthClientsListOutput = Schema.Struct({ + clients: Schema.Array(OAuthClientOutput), +}); +const OAuthCreateClientInput = Schema.Struct({ + owner: OwnerSchema, + slug: Schema.String, + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + grant: OAuthGrantSchema, + clientId: Schema.String, + clientSecret: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), +}); +const OAuthClientOutputRef = Schema.Struct({ + client: Schema.String, +}); +const OAuthRegisterDynamicInput = Schema.Struct({ + owner: OwnerSchema, + slug: Schema.String, + registrationEndpoint: Schema.String, + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), + scopes: Schema.Array(Schema.String), + tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)), + clientName: Schema.optional(Schema.String), + redirectUri: Schema.optional(Schema.NullOr(Schema.String)), + originIntegration: Schema.optional(Schema.NullOr(Schema.String)), +}); +const OAuthRemoveClientInput = Schema.Struct({ + owner: OwnerSchema, + slug: Schema.String, }); - const OAuthProbeInput = Schema.Struct({ - endpoint: Schema.String, - headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), - queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), + url: Schema.String, }); - const OAuthProbeOutput = Schema.Struct({ - resourceMetadata: Schema.NullOr(UnknownRecord), - resourceMetadataUrl: Schema.NullOr(Schema.String), - authorizationServerMetadata: Schema.NullOr(UnknownRecord), - authorizationServerMetadataUrl: Schema.NullOr(Schema.String), - authorizationServerUrl: Schema.NullOr(Schema.String), - supportsDynamicRegistration: Schema.Boolean, - isBearerChallengeEndpoint: Schema.Boolean, + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + resource: Schema.optional(Schema.NullOr(Schema.String)), + scopesSupported: Schema.optional(Schema.Array(Schema.String)), + registrationEndpoint: Schema.optional(Schema.NullOr(Schema.String)), + tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)), }); - const OAuthStartInput = Schema.Struct({ - credentialScope: Schema.optional(Schema.String), - endpoint: Schema.String, - connectionId: Schema.String, - pluginId: Schema.String, - identityLabel: Schema.optional(Schema.String), - redirectUrl: Schema.optional(Schema.String), - headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), - queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), - strategy: OAuthStrategySchema, -}); - -const OAuthStartOutput = Schema.Struct({ - sessionId: Schema.String, - authorizationUrl: Schema.NullOr(Schema.String), - completedConnection: Schema.NullOr(Schema.Struct({ connectionId: Schema.String })), - instructions: Schema.String, -}); - + client: Schema.String, + clientOwner: OwnerSchema, + owner: OwnerSchema, + name: Schema.String, + integration: Schema.String, + template: Schema.String, + identityLabel: Schema.optional(Schema.NullOr(Schema.String)), + redirectUri: Schema.optional(Schema.NullOr(Schema.String)), +}); +const OAuthStartOutput = Schema.Union([ + Schema.Struct({ + status: Schema.Literal("connected"), + connection: ConnectionOutput, + }), + Schema.Struct({ + status: Schema.Literal("redirect"), + authorizationUrl: Schema.String, + state: Schema.String, + }), +]); const OAuthCancelInput = Schema.Struct({ - credentialScope: Schema.optional(Schema.String), - sessionId: Schema.String, -}); - -const OAuthCancelOutput = Schema.Struct({ - cancelled: Schema.Boolean, + state: Schema.String, }); -const ScopesListOutputStd = schemaToStandard(ScopesListOutput); -const SecretsListOutputStd = schemaToStandard(SecretsListOutput); -const SecretsCreateInputStd = schemaToStandard< - typeof SecretsCreateInput.Type, - typeof SecretsCreateInput.Encoded ->(SecretsCreateInput); -const SecretsCreateOutputStd = schemaToStandard(SecretsCreateOutput); -const SecretPointerInputStd = schemaToStandard< - typeof SecretPointerInput.Type, - typeof SecretPointerInput.Encoded ->(SecretPointerInput); -const SecretScopedPointerInputStd = schemaToStandard< - typeof SecretScopedPointerInput.Type, - typeof SecretScopedPointerInput.Encoded ->(SecretScopedPointerInput); -const SecretStatusOutputStd = schemaToStandard(SecretStatusOutput); -const SecretUsagesOutputStd = schemaToStandard(SecretUsagesOutput); -const ProvidersOutputStd = schemaToStandard(ProvidersOutput); -const RemovedOutputStd = schemaToStandard(RemovedOutput); -const RefreshedOutputStd = schemaToStandard(RefreshedOutput); -const SourcesListOutputStd = schemaToStandard(SourcesListOutput); -const SourcesDetectInputStd = schemaToStandard< - typeof SourcesDetectInput.Type, - typeof SourcesDetectInput.Encoded ->(SourcesDetectInput); -const SourcesDetectOutputStd = schemaToStandard(SourcesDetectOutput); -const SourcesPresetsInputStd = schemaToStandard< - typeof SourcesPresetsInput.Type, - typeof SourcesPresetsInput.Encoded ->(SourcesPresetsInput); -const SourcesPresetsOutputStd = schemaToStandard(SourcesPresetsOutput); -const SourcesConfigureInputStd = schemaToStandard< - typeof SourcesConfigureInput.Type, - typeof SourcesConfigureInput.Encoded ->(SourcesConfigureInput); -const SourcesConfigureOutputStd = schemaToStandard(SourcesConfigureOutput); -const SourceLifecycleInputStd = schemaToStandard< - typeof SourceLifecycleInput.Type, - typeof SourceLifecycleInput.Encoded ->(SourceLifecycleInput); -const SourceBindingsListInputStd = schemaToStandard< - typeof SourceBindingsListInput.Type, - typeof SourceBindingsListInput.Encoded ->(SourceBindingsListInput); -const SourceBindingsResolveInputStd = schemaToStandard< - typeof SourceBindingsResolveInput.Type, - typeof SourceBindingsResolveInput.Encoded ->(SourceBindingsResolveInput); -const SourceBindingsSetInputStd = schemaToStandard< - typeof SourceBindingsSetInput.Type, - typeof SourceBindingsSetInput.Encoded ->(SourceBindingsSetInput); -const SourceBindingsRemoveInputStd = schemaToStandard< - typeof SourceBindingsRemoveInput.Type, - typeof SourceBindingsRemoveInput.Encoded ->(SourceBindingsRemoveInput); -const SourceBindingsListOutputStd = schemaToStandard(SourceBindingsListOutput); -const SourceBindingsResolveOutputStd = schemaToStandard(SourceBindingsResolveOutput); -const SourceBindingsSetOutputStd = schemaToStandard(SourceBindingsSetOutput); +// Standard-schema versions for the tool() builder. +const IntegrationsListOutputStd = schemaToStandard(IntegrationsListOutput); +const DetectInputStd = schemaToStandard(DetectInput); +const DetectOutputStd = schemaToStandard(DetectOutput); +const ConnectionsListInputStd = schemaToStandard(ConnectionsListInput); const ConnectionsListOutputStd = schemaToStandard(ConnectionsListOutput); -const ConnectionPointerInputStd = schemaToStandard< - typeof ConnectionPointerInput.Type, - typeof ConnectionPointerInput.Encoded ->(ConnectionPointerInput); -const ConnectionScopedPointerInputStd = schemaToStandard< - typeof ConnectionScopedPointerInput.Type, - typeof ConnectionScopedPointerInput.Encoded ->(ConnectionScopedPointerInput); -const ConnectionUsagesOutputStd = schemaToStandard(ConnectionUsagesOutput); +const ConnectionCreateHandoffInputStd = schemaToStandard(ConnectionCreateHandoffInput); +const ConnectionCreateHandoffOutputStd = schemaToStandard(ConnectionCreateHandoffOutput); +const ConnectionCreateInputStd = schemaToStandard(ConnectionCreateInput); +const ConnectionOutputStd = schemaToStandard(ConnectionOutput); +const ConnectionRefInputStd = schemaToStandard(ConnectionRefInput); +const ConnectionsRefreshOutputStd = schemaToStandard(ConnectionsRefreshOutput); +const RemovedOutputStd = schemaToStandard(RemovedOutput); +const CancelledOutputStd = schemaToStandard(CancelledOutput); +const ProvidersOutputStd = schemaToStandard(ProvidersOutput); +const ProviderItemsInputStd = schemaToStandard(ProviderItemsInput); +const ProviderItemsOutputStd = schemaToStandard(ProviderItemsOutput); const PoliciesListOutputStd = schemaToStandard(PoliciesListOutput); -const PolicyCreateInputStd = schemaToStandard< - typeof PolicyCreateInput.Type, - typeof PolicyCreateInput.Encoded ->(PolicyCreateInput); -const PolicyUpdateInputStd = schemaToStandard< - typeof PolicyUpdateInput.Type, - typeof PolicyUpdateInput.Encoded ->(PolicyUpdateInput); -const PolicyRemoveInputStd = schemaToStandard< - typeof PolicyRemoveInput.Type, - typeof PolicyRemoveInput.Encoded ->(PolicyRemoveInput); -const PolicyMutationOutputStd = schemaToStandard(PolicyMutationOutput); -const OAuthProbeInputStd = schemaToStandard< - typeof OAuthProbeInput.Type, - typeof OAuthProbeInput.Encoded ->(OAuthProbeInput); +const PolicyOutputStd = schemaToStandard(PolicyOutput); +const PolicyCreateInputStd = schemaToStandard(PolicyCreateInput); +const PolicyUpdateInputStd = schemaToStandard(PolicyUpdateInput); +const PolicyRemoveInputStd = schemaToStandard(PolicyRemoveInput); +const OAuthClientsListOutputStd = schemaToStandard(OAuthClientsListOutput); +const OAuthCreateClientInputStd = schemaToStandard(OAuthCreateClientInput); +const OAuthClientOutputRefStd = schemaToStandard(OAuthClientOutputRef); +const OAuthRegisterDynamicInputStd = schemaToStandard(OAuthRegisterDynamicInput); +const OAuthRemoveClientInputStd = schemaToStandard(OAuthRemoveClientInput); +const OAuthProbeInputStd = schemaToStandard(OAuthProbeInput); const OAuthProbeOutputStd = schemaToStandard(OAuthProbeOutput); -const OAuthStartInputStd = schemaToStandard< - typeof OAuthStartInput.Type, - typeof OAuthStartInput.Encoded ->(OAuthStartInput); +const OAuthStartInputStd = schemaToStandard(OAuthStartInput); const OAuthStartOutputStd = schemaToStandard(OAuthStartOutput); -const OAuthCancelInputStd = schemaToStandard< - typeof OAuthCancelInput.Type, - typeof OAuthCancelInput.Encoded ->(OAuthCancelInput); -const OAuthCancelOutputStd = schemaToStandard(OAuthCancelOutput); - -export interface CoreToolsPluginOptions { - readonly webBaseUrl?: string; -} - -class CoreToolsConfigurationError extends Data.TaggedError("CoreToolsConfigurationError")<{ - readonly message: string; -}> {} - -class CoreToolsScopeNotFoundError extends Data.TaggedError("CoreToolsScopeNotFoundError")<{ - readonly scope: string; - readonly message: string; -}> {} - -const findScopeByNameOrId = ( - scopes: readonly { readonly id: ScopeId; readonly name: string }[], - value: string, -) => scopes.find((scope) => scope.name === value || String(scope.id) === value); - -const resolveScopeInput = ( - scopes: readonly { readonly id: ScopeId; readonly name: string }[], - value: string | undefined, -) => { - if (value === undefined) { - const [onlyScope] = scopes; - return onlyScope && scopes.length === 1 - ? Effect.succeed(String(onlyScope.id)) - : Effect.fail( - new CoreToolsScopeNotFoundError({ - scope: "", - message: - scopes.length === 0 - ? "No visible scopes are available." - : "Multiple scopes are visible. Call scopes.list and pass the target scope id or name.", - }), - ); - } - - const scope = findScopeByNameOrId(scopes, value); - return scope - ? Effect.succeed(String(scope.id)) - : Effect.fail( - new CoreToolsScopeNotFoundError({ - scope: value, - message: `Unknown scope "${value}". Call scopes.list to see valid scope ids and names.`, - }), - ); -}; +const OAuthCancelInputStd = schemaToStandard(OAuthCancelInput); + +const connectionToOutput = (connection: Connection) => ({ + owner: connection.owner, + name: String(connection.name), + integration: String(connection.integration), + template: String(connection.template), + provider: String(connection.provider), + address: String(connection.address), + identityLabel: connection.identityLabel ?? null, + expiresAt: connection.expiresAt ?? null, + oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient), + oauthClientOwner: connection.oauthClientOwner ?? null, + oauthScope: connection.oauthScope ?? null, +}); + +const toolToOutput = (toolRow: Tool) => ({ + address: String(toolRow.address), + owner: toolRow.owner, + integration: String(toolRow.integration), + connection: String(toolRow.connection), + name: String(toolRow.name), + pluginId: toolRow.pluginId, + description: toolRow.description, +}); + +const connectionRefFromInput = (input: typeof ConnectionRefInput.Type) => ({ + owner: input.owner as Owner, + integration: IntegrationSlug.make(input.integration), + name: ConnectionName.make(input.name), +}); + +const originFromInput = ( + origin: typeof ConnectionInputOriginInput.Type, +): ConnectionInputOrigin => ({ + from: { + provider: ProviderKey.make(origin.from.provider), + id: ProviderItemId.make(origin.from.id), + }, +}); + +const createConnectionInputFromTool = ( + input: typeof ConnectionCreateInput.Type, +): CreateConnectionInput => { + const base = { + owner: input.owner as Owner, + name: ConnectionName.make(input.name), + integration: IntegrationSlug.make(input.integration), + template: AuthTemplateSlug.make(input.template), + identityLabel: input.identityLabel ?? null, + }; -const normalizeCredentialBindingValue = ( - value: typeof CredentialBindingValue.Encoded, -): CredentialBindingValue => { - if (value.kind === "text") { - return value; - } - if (value.kind === "secret") { + if (input.from !== undefined) { return { - kind: "secret", - secretId: SecretId.make(value.secretId), - ...(value.secretScopeId ? { secretScopeId: ScopeId.make(value.secretScopeId) } : {}), + ...base, + from: { + provider: ProviderKey.make(input.from.provider), + id: ProviderItemId.make(input.from.id), + }, }; } return { - kind: "connection", - connectionId: ConnectionId.make(value.connectionId), + ...base, + inputs: Object.fromEntries( + Object.entries(input.inputs ?? {}).map(([variable, origin]) => [ + variable, + originFromInput(origin), + ]), + ), }; }; -const oauthToolFailure = (code: string, message: string, details?: unknown) => - ToolResult.fail({ - code, - message, - ...(details === undefined ? {} : { details }), - }); - -const requireWebBaseUrl = (value: string | undefined) => - value - ? Effect.succeed(value.replace(/\/$/, "")) - : Effect.fail( - new CoreToolsConfigurationError({ - message: "This executor did not provide a webBaseUrl for browser handoff flows.", - }), - ); +const connectionCreateHandoffUrl = ( + webBaseUrl: string | undefined, + input: typeof ConnectionCreateHandoffInput.Type, +): string => { + const search = new URLSearchParams({ addAccount: "1" }); + if (input.owner !== undefined) search.set("owner", input.owner); + if (input.template !== undefined) search.set("template", input.template); + if (input.label !== undefined) search.set("label", input.label); + const path = `/integrations/${encodeURIComponent(input.integration)}?${search.toString()}`; + if (webBaseUrl === undefined || webBaseUrl.length === 0) return path; + return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString(); +}; -const policyOutput = (policy: { - readonly id: string; - readonly scopeId: string; - readonly pattern: string; - readonly action: typeof ToolPolicyActionSchema.Type; - readonly position: string; - readonly createdAt: Date; - readonly updatedAt: Date; -}) => ({ - id: String(policy.id), - scopeId: String(policy.scopeId), - pattern: policy.pattern, - action: policy.action, - position: policy.position, - createdAt: policy.createdAt.getTime(), - updatedAt: policy.updatedAt.getTime(), -}); +export interface CoreToolsPluginOptions { + readonly webBaseUrl?: string; + readonly includeProviders?: boolean; +} export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {}) => ({ id: "core-tools" as const, @@ -509,486 +396,343 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { name: "Executor", tools: [ tool({ - name: "scopes.list", + name: "integrations.list", description: - "List visible executor scopes. Call this before write tools when more than one scope is visible; single-scope local executors can usually omit scope inputs.", - outputSchema: ScopesListOutputStd, + "List integrations in the workspace catalog (slug, description, owning plugin kind). Connections authenticate against these.", + outputSchema: IntegrationsListOutputStd, execute: (_args, { ctx }) => - Effect.succeed({ - scopes: ctx.scopes.map((s) => ({ id: String(s.id), name: s.name })), - }), + Effect.map(ctx.core.integrations.list(), (integrations) => ({ + integrations: integrations.map((i) => ({ + slug: String(i.slug), + description: i.description, + kind: i.kind, + canRemove: i.canRemove, + canRefresh: i.canRefresh, + })), + })), }), tool({ - name: "secrets.list", + name: "integrations.detect", description: - "List visible secrets by id, name, and provider. This never returns values. Use returned ids in source configuration or OAuth client credential strategies.", - outputSchema: SecretsListOutputStd, - execute: (_args, { ctx }) => - Effect.map(ctx.secrets.list(), (refs) => ({ - secrets: refs.map((r) => ({ - id: String(r.id), - scopeId: String(r.scopeId), + "Given a URL, ask every plugin whether it recognizes it, returning best-confidence matches so the UI can pre-fill onboarding for the right plugin.", + inputSchema: DetectInputStd, + outputSchema: DetectOutputStd, + execute: (input: typeof DetectInput.Type, { ctx }) => + Effect.map(ctx.core.integrations.detect(input.url), (results) => ({ + results: results.map((r) => ({ + kind: r.kind, + confidence: r.confidence, + endpoint: r.endpoint, name: r.name, - provider: r.provider, + slug: r.slug, })), })), }), tool({ - name: "secrets.create", + name: "connections.list", description: - "Create a secret placeholder and return a browser URL for the user to enter the sensitive value. Never ask the user to paste passwords, tokens, client secrets, or API keys into chat. In a single-scope local executor, omit `scope`; otherwise call `scopes.list` and pass the target credential scope id or name. The optional `provider` is the Executor secret storage backend, not the API vendor; omit it unless the user explicitly chose a value returned by `secrets.providers`.", - inputSchema: SecretsCreateInputStd, - outputSchema: SecretsCreateOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const webBaseUrl = yield* requireWebBaseUrl(options.webBaseUrl); - if (input.provider) { - const providers = yield* ctx.secrets.providers(); - if (!providers.includes(input.provider)) { - return oauthToolFailure( - "secret_provider_not_found", - `Unknown secret storage provider "${input.provider}". Omit provider unless the user chose one from secrets.providers.`, - { providers }, - ); - } - } - const targetScope = yield* resolveScopeInput(ctx.scopes, input.scope); - - const secretId = crypto.randomUUID(); - const url = new URL(`${webBaseUrl}/secrets`); - url.searchParams.set("scope", targetScope); - url.searchParams.set("name", input.name); - url.searchParams.set("secretId", secretId); - if (input.provider) url.searchParams.set("provider", input.provider); - return { - id: secretId, - url: url.toString(), - instructions: - "The user needs to open this URL and set the secret value in the browser. Until the user saves the value there, this secret is only a placeholder and will not be available for binding. After the user saves it, call secrets.status for this id before using it in source configuration.", - }; - }).pipe( - Effect.catchTags({ - CoreToolsConfigurationError: ({ message }) => - Effect.succeed(oauthToolFailure("secret_handoff_not_configured", message)), - CoreToolsScopeNotFoundError: ({ message, scope }) => - Effect.succeed(oauthToolFailure("scope_not_found", message, { scope })), + "List saved connections (the credential for one integration). Never returns the credential value. Optionally filter by integration or owner.", + inputSchema: ConnectionsListInputStd, + outputSchema: ConnectionsListOutputStd, + execute: (input: typeof ConnectionsListInput.Type, { ctx }) => + Effect.map( + ctx.connections.list({ + integration: + input.integration === undefined + ? undefined + : IntegrationSlug.make(input.integration), + owner: input.owner === undefined ? undefined : (input.owner as Owner), + }), + (connections) => ({ + connections: connections.map(connectionToOutput), }), ), }), tool({ - name: "secrets.status", - description: - "Check whether a user-visible secret id has a backing value without revealing that value. Use this after a browser handoff from `secrets.create` before wiring the secret into a source.", - inputSchema: SecretPointerInputStd, - outputSchema: SecretStatusOutputStd, - execute: (input, { ctx }) => - Effect.map(ctx.secrets.status(input.id), (status) => ({ id: input.id, status })), - }), - tool({ - name: "secrets.usages", - description: - "List sources and credential slots that reference a secret. Call this before removing a secret so the user can detach it first if needed.", - inputSchema: SecretPointerInputStd, - outputSchema: SecretUsagesOutputStd, - execute: (input, { ctx }) => - Effect.map(ctx.secrets.usages(input.id), (usages) => ({ usages })), - }), - tool({ - name: "secrets.providers", - description: - "List registered secret storage providers. Only use these exact values for the optional `provider` field in `secrets.create`; do not use API vendor names such as Vercel, GitHub, Stripe, or Google. Sensitive values still must be entered through the returned browser URL.", - outputSchema: ProvidersOutputStd, - execute: (_args, { ctx }) => - Effect.map(ctx.secrets.providers(), (providers) => ({ providers })), - }), - tool({ - name: "secrets.remove", - description: - "Remove a user-visible secret from a target scope. Call `secrets.usages` first; removal is refused while sources still reference the secret. Connection-owned token secrets cannot be removed here; remove the connection instead.", - annotations: { - requiresApproval: true, - approvalDescription: "Remove an Executor secret", - }, - inputSchema: SecretScopedPointerInputStd, - outputSchema: RemovedOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); - return yield* Effect.as( - ctx.secrets.remove({ - id: SecretId.make(input.id), - targetScope: ScopeId.make(targetScope), - }), - { removed: true }, - ); - }), - }), - tool({ - name: "sources.list", + name: "connections.create", description: - "List configured and built-in sources. Use this to find source ids/scopes before calling plugin-specific configureSource tools, `sources.bindings.*`, refresh, remove, or tool discovery.", - outputSchema: SourcesListOutputStd, - execute: (_args, { ctx }) => - Effect.map(ctx.core.sources.list(), (sources) => ({ sources })), - }), - tool({ - name: "sources.detect", - description: - "Detect which plugin can add or configure a URL. This is the same URL auto-detection used by the Executor web Connect dialog. Use this when the user gives a URL but not a source type; then call the matching plugin add tool such as `openapi.previewSpec` + `openapi.addSource`, `graphql.addSource`, or `mcp.addSource`.", - inputSchema: SourcesDetectInputStd, - outputSchema: SourcesDetectOutputStd, - execute: (input, { ctx }) => - Effect.map(ctx.core.sources.detect(input.url), (results) => ({ results })), - }), - tool({ - name: "sources.presets", - description: - "List the same popular source presets shown in Executor web's Connect dialog. Use this before asking the user what to connect; filter with `query` for names like GitHub, Stripe, Axiom, Google Calendar, Linear, or OpenAI. For OpenAPI presets, including Google Discovery URLs, pass `url` to the preview/probe and add tools. For MCP and GraphQL presets, pass `endpoint`. For stdio MCP presets, use the returned command/args/env.", - inputSchema: SourcesPresetsInputStd, - outputSchema: SourcesPresetsOutputStd, - execute: (input, { ctx }) => - Effect.sync(() => { - const query = input.query?.trim().toLowerCase() ?? ""; - const pluginId = input.pluginId?.trim(); - const featuredOnly = input.featuredOnly ?? false; - const limit = Math.max(0, Math.trunc(input.limit ?? 50)); - const presets = ctx.core.sources - .presets() - .filter((preset) => (pluginId ? preset.pluginId === pluginId : true)) - .filter((preset) => (featuredOnly ? preset.featured === true : true)) - .filter((preset) => { - if (query.length === 0) return true; - const corpus = - `${preset.name} ${preset.summary} ${preset.pluginId} ${preset.id}`.toLowerCase(); - return corpus.includes(query); - }) - .slice(0, limit); - return { presets }; - }), - }), - tool({ - name: "sources.configure", - description: - 'Low-level escape hatch for configuring an existing source through its owning plugin. Prefer plugin-specific tools such as `openapi.configureSource`, `graphql.configureSource`, or `mcp.configureSource`; this accepts plugin config as `unknown` for repair and compatibility cases. Use `secrets.create`/`oauth.start` first for sensitive inputs. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}` when the plugin schema supports them.', - annotations: { - requiresApproval: true, - approvalDescription: "Configure an Executor source", - }, - inputSchema: SourcesConfigureInputStd, - outputSchema: SourcesConfigureOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); - const targetScope = yield* resolveScopeInput(ctx.scopes, input.scope); - const result = yield* ctx.core.sources.configure({ - ...input, - source: { ...input.source, scope: sourceScope }, - scope: targetScope, - }); - return { result }; - }), + "Low-level create or replace for a saved connection from provider item references. For normal API keys/tokens, use `connections.createHandoff` so the user enters the credential in the web UI. OAuth credentials should use `oauth.start`.", + inputSchema: ConnectionCreateInputStd, + outputSchema: ConnectionOutputStd, + execute: (input: typeof ConnectionCreateInput.Type, { ctx }) => + Effect.map( + ctx.connections.create(createConnectionInputFromTool(input)), + connectionToOutput, + ), }), tool({ - name: "sources.refresh", + name: "connections.createHandoff", description: - "Refresh a configurable source's registered tools from its backing spec/server. Use `sources.list` first to get the source id and owning scope, then refresh the owning scope.", - annotations: { - requiresApproval: true, - approvalDescription: "Refresh an Executor source", + "Return a browser URL that opens the Add account flow for one integration. Use this for API keys/tokens so the user enters secrets directly in the web UI instead of sending them through the agent. Optionally preselect owner, auth template, and a non-secret label.", + inputSchema: ConnectionCreateHandoffInputStd, + outputSchema: ConnectionCreateHandoffOutputStd, + execute: (input: typeof ConnectionCreateHandoffInput.Type) => { + const url = connectionCreateHandoffUrl(options.webBaseUrl, input); + return Effect.succeed({ + url, + instructions: + "Ask the user to open this URL and add the account in the Executor web UI. Do not ask them to paste the credential value into chat. After they finish, call connections.list for the integration to discover the created connection.", + }); }, - inputSchema: SourceLifecycleInputStd, - outputSchema: RefreshedOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); - return yield* Effect.as(ctx.core.sources.refresh({ ...input, targetScope }), { - refreshed: true, - }); - }), }), tool({ - name: "sources.remove", + name: "connections.remove", description: - "Remove a configurable source and its registered tools from a target scope. Use `sources.list` and, when credentials are involved, `sources.bindings.list` first so the user can confirm exactly what will be removed.", - annotations: { - requiresApproval: true, - approvalDescription: "Remove an Executor source", - }, - inputSchema: SourceLifecycleInputStd, + "Remove a saved connection and its produced tools by owner, integration, and connection name.", + inputSchema: ConnectionRefInputStd, outputSchema: RemovedOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); - return yield* Effect.as(ctx.core.sources.remove({ ...input, targetScope }), { - removed: true, - }); - }), + execute: (input: typeof ConnectionRefInput.Type, { ctx }) => + Effect.map(ctx.connections.remove(connectionRefFromInput(input)), () => ({ + removed: true, + })), }), tool({ - name: "sources.bindings.list", + name: "connections.refresh", description: - "List credential bindings for a source. Use this to verify that secrets or OAuth connections were bound after a plugin-specific configureSource tool.", - inputSchema: SourceBindingsListInputStd, - outputSchema: SourceBindingsListOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); - const bindings = yield* ctx.core.sources.listBindings({ - source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, - }); - return { bindings }; - }), + "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.", + inputSchema: ConnectionRefInputStd, + outputSchema: ConnectionsRefreshOutputStd, + execute: (input: typeof ConnectionRefInput.Type, { ctx }) => + Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({ + tools: tools.map(toolToOutput), + })), }), + // removed: tools.list — the cross-connection tool catalog is an + // executor-surface read, not exposed on PluginCtx. + ...(options.includeProviders === false + ? [] + : [ + tool({ + name: "providers.list", + description: + "List registered credential provider keys (the storage backends, not API vendors). Use `providers.items` to browse a backend's entries.", + outputSchema: ProvidersOutputStd, + execute: (_args, { ctx }) => + Effect.map(ctx.providers.list(), (providers) => ({ + providers: providers.map((p) => String(p)), + })), + }), + tool({ + name: "providers.items", + description: + "Browse a credential provider's items for discovery (pick a 1Password / keychain entry). Returns opaque ids and labels, never values.", + inputSchema: ProviderItemsInputStd, + outputSchema: ProviderItemsOutputStd, + execute: (input: typeof ProviderItemsInput.Type, { ctx }) => + Effect.map(ctx.providers.items(ProviderKey.make(input.provider)), (items) => ({ + items: items.map((i) => ({ id: String(i.id), name: i.name })), + })), + }), + ]), tool({ - name: "sources.bindings.resolve", + name: "oauth.clients.list", description: - "Resolve the effective credential binding for one source slot, accounting for scope shadowing. Values are references only; plaintext is never returned.", - inputSchema: SourceBindingsResolveInputStd, - outputSchema: SourceBindingsResolveOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); - const binding = yield* ctx.core.sources.resolveBinding({ - source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, - slotKey: input.slotKey, - }); - return { binding }; - }), + "List registered OAuth clients visible to this executor. Returns metadata only; client secrets are never returned.", + outputSchema: OAuthClientsListOutputStd, + execute: (_args, { ctx }) => + Effect.map(ctx.oauth.listClients(), (clients) => ({ + clients: clients.map((client) => ({ + owner: client.owner, + slug: String(client.slug), + grant: client.grant, + authorizationUrl: client.authorizationUrl, + tokenUrl: client.tokenUrl, + resource: client.resource ?? null, + clientId: client.clientId, + })), + })), }), tool({ - name: "sources.bindings.set", + name: "oauth.clients.create", description: - "Set one credential binding for a source slot. Prefer plugin-specific configureSource tools for normal flows because they name the right credential fields. Use this low-level tool only when a plugin or status output has given an exact slot key.", - annotations: { - requiresApproval: true, - approvalDescription: "Set a source credential binding", - }, - inputSchema: SourceBindingsSetInputStd, - outputSchema: SourceBindingsSetOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const scope = yield* resolveScopeInput(ctx.scopes, input.scope); - const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); - const binding = yield* ctx.core.sources.setBinding({ - scope: ScopeId.make(scope), - source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, - slotKey: input.slotKey, - value: normalizeCredentialBindingValue(input.value), - }); - return { binding }; - }), + "Register or replace an owner-scoped OAuth client from explicit client credentials. Use grant `client_credentials` for machine OAuth or `authorization_code` for browser consent flows.", + inputSchema: OAuthCreateClientInputStd, + outputSchema: OAuthClientOutputRefStd, + execute: (input: typeof OAuthCreateClientInput.Type, { ctx }) => + Effect.map( + ctx.oauth.createClient({ + owner: input.owner as Owner, + slug: OAuthClientSlug.make(input.slug), + authorizationUrl: input.authorizationUrl, + tokenUrl: input.tokenUrl, + grant: input.grant, + clientId: input.clientId, + clientSecret: input.clientSecret, + resource: input.resource ?? null, + }), + (client) => ({ client: String(client) }), + ), }), tool({ - name: "sources.bindings.remove", + name: "oauth.clients.registerDynamic", description: - "Remove one credential binding from a source slot at a target scope. Use `sources.bindings.list` first so the user can confirm the exact binding being removed.", - annotations: { - requiresApproval: true, - approvalDescription: "Remove a source credential binding", - }, - inputSchema: SourceBindingsRemoveInputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const scope = yield* resolveScopeInput(ctx.scopes, input.scope); - const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); - return yield* Effect.asVoid( - ctx.core.sources.removeBinding({ - scope: ScopeId.make(scope), - source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, - slotKey: input.slotKey, - }), - ); - }), + "Register an OAuth client through RFC 7591 Dynamic Client Registration and save the minted client for later `oauth.start` calls.", + inputSchema: OAuthRegisterDynamicInputStd, + outputSchema: OAuthClientOutputRefStd, + execute: (input: typeof OAuthRegisterDynamicInput.Type, { ctx }) => + Effect.map( + ctx.oauth.registerDynamicClient({ + owner: input.owner as Owner, + slug: OAuthClientSlug.make(input.slug), + registrationEndpoint: input.registrationEndpoint, + authorizationUrl: input.authorizationUrl, + tokenUrl: input.tokenUrl, + resource: input.resource ?? null, + scopes: input.scopes, + tokenEndpointAuthMethodsSupported: input.tokenEndpointAuthMethodsSupported, + clientName: input.clientName, + redirectUri: input.redirectUri, + originIntegration: + input.originIntegration == null + ? null + : IntegrationSlug.make(input.originIntegration), + }), + (client) => ({ client: String(client) }), + ), }), tool({ - name: "connections.list", + name: "oauth.clients.remove", description: - "List OAuth/sign-in connections. This returns metadata and token secret ids, never token values. Use it to verify that `oauth.start` completed, then bind the connection id with the relevant plugin-specific configureSource tool.", - outputSchema: ConnectionsListOutputStd, - execute: (_args, { ctx }) => - Effect.map(ctx.connections.list(), (connections) => ({ connections })), + "Remove an owner-scoped OAuth client by owner and slug. Existing connections are not cascaded.", + inputSchema: OAuthRemoveClientInputStd, + outputSchema: RemovedOutputStd, + execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) => + Effect.map( + ctx.oauth.removeClient(input.owner as Owner, OAuthClientSlug.make(input.slug)), + () => ({ removed: true }), + ), }), tool({ - name: "connections.usages", + name: "oauth.probe", description: - "List sources and credential slots that reference an OAuth/sign-in connection. Call this before removing a connection so the user can detach it first if needed.", - inputSchema: ConnectionPointerInputStd, - outputSchema: ConnectionUsagesOutputStd, - execute: (input, { ctx }) => - Effect.map(ctx.connections.usages(input.id), (usages) => ({ usages })), + "Discover OAuth authorization-server metadata from an issuer or protected-resource URL so client registration can be pre-filled.", + inputSchema: OAuthProbeInputStd, + outputSchema: OAuthProbeOutputStd, + execute: (input: typeof OAuthProbeInput.Type, { ctx }) => + Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({ + authorizationUrl: result.authorizationUrl, + tokenUrl: result.tokenUrl, + resource: result.resource ?? null, + scopesSupported: result.scopesSupported, + registrationEndpoint: result.registrationEndpoint ?? null, + tokenEndpointAuthMethodsSupported: result.tokenEndpointAuthMethodsSupported, + })), }), tool({ - name: "connections.providers", + name: "oauth.start", description: - "List registered connection providers. Use this to understand which OAuth/sign-in connection kinds this executor can mint and refresh.", - outputSchema: ProvidersOutputStd, - execute: (_args, { ctx }) => - Effect.map(ctx.connections.providers(), (providers) => ({ providers })), + "Start OAuth through a registered client to mint a connection for an integration. `client_credentials` clients return `connected`; authorization-code clients return an authorization URL and state.", + inputSchema: OAuthStartInputStd, + outputSchema: OAuthStartOutputStd, + execute: (input: typeof OAuthStartInput.Type, { ctx }) => + Effect.map( + ctx.oauth.start({ + client: OAuthClientSlug.make(input.client), + clientOwner: input.clientOwner as Owner, + owner: input.owner as Owner, + name: ConnectionName.make(input.name), + integration: IntegrationSlug.make(input.integration), + template: AuthTemplateSlug.make(input.template), + identityLabel: input.identityLabel, + redirectUri: input.redirectUri, + }), + (result) => + result.status === "connected" + ? { + status: "connected" as const, + connection: connectionToOutput(result.connection), + } + : { + status: "redirect" as const, + authorizationUrl: result.authorizationUrl, + state: String(result.state), + }, + ), }), tool({ - name: "connections.remove", + name: "oauth.cancel", description: - "Remove an OAuth/sign-in connection and its owned token secrets from a target scope. Call `connections.usages` first; removal is refused while sources still reference the connection.", - annotations: { - requiresApproval: true, - approvalDescription: "Remove an Executor connection", - }, - inputSchema: ConnectionScopedPointerInputStd, - outputSchema: RemovedOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); - return yield* Effect.as( - ctx.connections.remove({ - id: ConnectionId.make(input.id), - targetScope: ScopeId.make(targetScope), - }), - { removed: true }, - ); - }), + "Cancel an in-flight OAuth authorization-code session by state after the user abandons the flow.", + inputSchema: OAuthCancelInputStd, + outputSchema: CancelledOutputStd, + execute: (input: typeof OAuthCancelInput.Type, { ctx }) => + Effect.map(ctx.oauth.cancel(OAuthState.make(input.state)), () => ({ cancelled: true })), }), tool({ name: "policies.list", description: - "List tool approval policies visible to this executor, sorted in evaluation order. Use this before creating, updating, reordering, or removing policies.", + "List tool policies (approve / require_approval / block) for org and user owners, in evaluation order.", outputSchema: PoliciesListOutputStd, execute: (_args, { ctx }) => Effect.map(ctx.core.policies.list(), (policies) => ({ - policies: policies.map(policyOutput), + policies: policies.map((p) => ({ + id: String(p.id), + owner: p.owner, + pattern: p.pattern, + action: p.action, + position: p.position, + })), })), }), tool({ name: "policies.create", description: - 'Create a tool approval policy. Patterns are exact tool ids, a trailing wildcard such as `executor.openapi.*`, or `*`. Actions are `"approve"`, `"require_approval"`, or `"block"`. Omit `position` to place the policy at the top of the target scope.', - annotations: { - requiresApproval: true, - approvalDescription: "Create an Executor tool policy", - }, + "Create a tool policy. `pattern` matches a tool address tail (`integration.connection.tool`, `integration.*`, `*`); `action` is approve/require_approval/block. `owner` is org (workspace guardrail) or user (personal).", inputSchema: PolicyCreateInputStd, - outputSchema: PolicyMutationOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); - const policy = yield* ctx.core.policies.create({ ...input, targetScope }); - return { policy: policyOutput(policy) }; - }), + outputSchema: PolicyOutputStd, + execute: (input: typeof PolicyCreateInput.Type, { ctx }) => + Effect.map( + ctx.core.policies.create({ + owner: input.owner as Owner, + pattern: input.pattern, + action: input.action, + }), + (p) => ({ + id: String(p.id), + owner: p.owner, + pattern: p.pattern, + action: p.action, + position: p.position, + }), + ), }), tool({ name: "policies.update", - description: - "Update or reorder an approval policy. Use `policies.list` first; preserve fields you are not changing, and use the listed `position` values when computing a new order.", - annotations: { - requiresApproval: true, - approvalDescription: "Update an Executor tool policy", - }, + description: "Update a tool policy's pattern and/or action by id + owner.", inputSchema: PolicyUpdateInputStd, - outputSchema: PolicyMutationOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); - const policy = yield* ctx.core.policies.update({ ...input, targetScope }); - return { policy: policyOutput(policy) }; - }), + outputSchema: PolicyOutputStd, + execute: (input: typeof PolicyUpdateInput.Type, { ctx }) => + Effect.map( + ctx.core.policies.update({ + id: input.id, + owner: input.owner as Owner, + pattern: input.pattern, + action: input.action, + }), + (p) => ({ + id: String(p.id), + owner: p.owner, + pattern: p.pattern, + action: p.action, + position: p.position, + }), + ), }), tool({ name: "policies.remove", - description: - "Remove an approval policy from a target scope. Use `policies.list` first so the user can confirm the exact rule id and pattern.", - annotations: { - requiresApproval: true, - approvalDescription: "Remove an Executor tool policy", - }, + description: "Remove a tool policy by id + owner.", inputSchema: PolicyRemoveInputStd, outputSchema: RemovedOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); - return yield* Effect.as(ctx.core.policies.remove({ ...input, targetScope }), { - removed: true, - }); - }), - }), - tool({ - name: "oauth.probe", - description: - 'Probe an OAuth-protected endpoint before starting OAuth. For dynamic MCP-style OAuth, call this first; if `supportsDynamicRegistration` is true, call `oauth.start` with strategy `{kind:"dynamic-dcr"}`. If false, create client id/secret secrets in the browser and use an `authorization-code` strategy.', - inputSchema: OAuthProbeInputStd, - outputSchema: OAuthProbeOutputStd, - execute: (input, { ctx }) => - ctx.oauth - .probe(input) - .pipe( - Effect.catchTag("OAuthProbeError", ({ message }) => - Effect.succeed(oauthToolFailure("oauth_probe_failed", message)), - ), - ), - }), - tool({ - name: "oauth.start", - description: - "Start an OAuth flow and return the authorization URL the user must open in a browser. `credentialScope` chooses where Executor stores the OAuth connection/token secrets; omit it only in a single-scope local executor, otherwise call `scopes.list` and ask whether the connection should be personal/user-scoped or organization-scoped. OAuth permission scopes belong in `strategy.scopes`. Never put OAuth passwords, authorization codes, or client secrets in chat. For confidential clients, first call `secrets.create` for client id/secret and pass those secret ids in the strategy. After the browser callback completes, call `connections.list`, then configure the source with the returned connection id.", - inputSchema: OAuthStartInputStd, - outputSchema: OAuthStartOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const webBaseUrl = yield* requireWebBaseUrl(options.webBaseUrl); - const tokenScope = yield* resolveScopeInput(ctx.scopes, input.credentialScope); - const result = yield* ctx.oauth.start({ - endpoint: input.endpoint, - headers: input.headers, - queryParams: input.queryParams, - redirectUrl: input.redirectUrl ?? `${webBaseUrl}/api/oauth/callback`, - connectionId: input.connectionId, - tokenScope, - strategy: input.strategy, - pluginId: input.pluginId, - identityLabel: input.identityLabel, - }); - return { - ...result, - instructions: - result.authorizationUrl === null - ? "This OAuth flow completed without a browser handoff. The OAuth connection/token secrets were saved to the selected credential scope. Call connections.list to verify the connection id, then pass that connection id to the relevant source configuration tool." - : "The user needs to open this authorization URL in a browser and complete the OAuth/sign-in flow. Until the browser callback completes, no connection is available for binding. After the user finishes sign-in, call connections.list to find the connection id, then pass that connection id to the relevant source configuration tool. The OAuth connection/token secrets are saved to the selected credential scope.", - }; - }).pipe( - Effect.catchTags({ - CoreToolsConfigurationError: ({ message }) => - Effect.succeed(oauthToolFailure("oauth_start_not_configured", message)), - CoreToolsScopeNotFoundError: ({ message, scope }) => - Effect.succeed(oauthToolFailure("scope_not_found", message, { scope })), - OAuthStartError: ({ message, error, errorDescription }) => - Effect.succeed( - oauthToolFailure("oauth_start_failed", message, { - ...(error ? { error } : {}), - ...(errorDescription ? { errorDescription } : {}), - }), - ), + execute: (input: typeof PolicyRemoveInput.Type, { ctx }) => + Effect.map( + ctx.core.policies.remove({ + id: input.id, + owner: input.owner as Owner, }), - ), - }), - tool({ - name: "oauth.cancel", - description: - "`credentialScope` must match where `oauth.start` saved the pending browser handoff. Cancel it if the user declines or the wrong flow was started.", - inputSchema: OAuthCancelInputStd, - outputSchema: OAuthCancelOutputStd, - execute: (input, { ctx }) => - Effect.gen(function* () { - const scope = yield* resolveScopeInput(ctx.scopes, input.credentialScope); - return yield* Effect.as(ctx.oauth.cancel(input.sessionId, scope), { - cancelled: true, - }); - }).pipe( - Effect.catchTag("CoreToolsScopeNotFoundError", ({ message, scope }) => - Effect.succeed(oauthToolFailure("scope_not_found", message, { scope })), - ), + () => ({ removed: true }), ), }), ], }, ], })); - -export default coreToolsPlugin; diff --git a/packages/core/sdk/src/credential-bindings.test.ts b/packages/core/sdk/src/credential-bindings.test.ts deleted file mode 100644 index c3526ef00..000000000 --- a/packages/core/sdk/src/credential-bindings.test.ts +++ /dev/null @@ -1,847 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Predicate, Result } from "effect"; -import { withQueryContext } from "fumadb/query"; - -import { CreateConnectionInput, TokenMaterial } from "./connections"; -import { createExecutor, type Executor } from "./executor"; -import { ConnectionId, ScopeId, SecretId } from "./ids"; -import { definePlugin, type AnyPlugin } from "./plugin"; -import { Scope } from "./scope"; -import { RemoveSecretInput, SetSecretInput, type SecretProvider } from "./secrets"; -import { makeTestConfig } from "./testing"; - -const TEST_PLUGIN_ID = "credentialTest"; -const TEST_SOURCE_ID = "shared-api"; -const TEST_SLOT = "request.header.Authorization"; - -const scope = (id: string, name = id) => - Scope.make({ - id: ScopeId.make(id), - name, - createdAt: new Date(), - }); - -const makeMemorySecretProvider = (): SecretProvider => { - const store = new Map(); - const key = (scopeId: string, id: string) => `${scopeId}\u0000${id}`; - return { - key: "memory", - writable: true, - get: (id, scopeId) => Effect.sync(() => store.get(key(scopeId, id)) ?? null), - has: (id, scopeId) => Effect.sync(() => store.has(key(scopeId, id))), - set: (id, value, scopeId) => - Effect.sync(() => { - store.set(key(scopeId, id), value); - }), - delete: (id, scopeId) => Effect.sync(() => store.delete(key(scopeId, id))), - list: () => - Effect.sync(() => - Array.from(store.keys()).map((raw) => { - const id = raw.split("\u0000", 2)[1] ?? raw; - return { id, name: id }; - }), - ), - }; -}; - -const memorySecretsPlugin = (provider: SecretProvider) => - definePlugin(() => ({ - id: "memorySecrets" as const, - storage: () => ({}), - secretProviders: [provider], - }))(); - -const memoryConnectionPlugin = definePlugin(() => ({ - id: "memoryConnection" as const, - storage: () => ({}), - connectionProviders: [{ key: "memory-connection" }], -})); - -const credentialTestPlugin = definePlugin(() => ({ - id: TEST_PLUGIN_ID, - storage: () => ({}), - extension: (ctx) => ({ - registerSource: (targetScope: ScopeId) => - ctx.core.sources.register({ - id: TEST_SOURCE_ID, - scope: targetScope, - kind: "test-api", - name: "Shared API", - canRemove: true, - tools: [{ name: "read", description: "read from the shared API" }], - }), - }), -})); - -const makeHarness = () => { - const scopes = { - org: scope("org", "Org"), - workspace: scope("workspace", "Workspace"), - userWorkspaceA: scope("user-workspace-a", "User A Workspace"), - userWorkspaceB: scope("user-workspace-b", "User B Workspace"), - }; - const plugins = [ - memorySecretsPlugin(makeMemorySecretProvider()), - memoryConnectionPlugin(), - credentialTestPlugin(), - ] as const; - const config = makeTestConfig({ plugins }); - const create = ( - visibleScopes: readonly Scope[], - configuredPlugins: TPlugins, - ) => - createExecutor({ - ...config, - scopes: visibleScopes, - plugins: configuredPlugins, - onElicitation: "accept-all", - }); - - return { - dbFor: (visibleScopes: readonly Scope[]) => - withQueryContext(config.db, { - allowedScopeIds: new Set(visibleScopes.map((visibleScope) => String(visibleScope.id))), - }), - scopes, - create: (visibleScopes: readonly Scope[]) => create(visibleScopes, plugins), - }; -}; - -const setSecret = (executor: Executor, scopeId: ScopeId, id: string, value: string) => - executor.secrets.set( - SetSecretInput.make({ - id: SecretId.make(id), - scope: scopeId, - name: id, - value, - }), - ); - -const createConnection = (executor: Executor, scopeId: ScopeId, id: string) => - executor.connections.create( - CreateConnectionInput.make({ - id: ConnectionId.make(id), - scope: scopeId, - provider: "memory-connection", - identityLabel: "Test User", - accessToken: TokenMaterial.make({ - secretId: SecretId.make(`${id}.access_token`), - name: "Access", - value: "access-token", - }), - refreshToken: null, - expiresAt: null, - oauthScope: null, - providerState: null, - }), - ); - -describe("credential bindings", () => { - it.effect("resolves a user-workspace credential for an inherited org source", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.workspace, - harness.scopes.org, - ]); - yield* setSecret(userExecutor, harness.scopes.userWorkspaceA.id, "api-token", "sk-user-a"); - const binding = yield* userExecutor.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("api-token") }, - }); - - const resolved = yield* userExecutor.credentialBindings.resolve({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - }); - - expect(binding.scopeId).toBe(harness.scopes.userWorkspaceA.id); - expect(resolved.status).toBe("resolved"); - expect(resolved.bindingScopeId).toBe(harness.scopes.userWorkspaceA.id); - expect(resolved.kind).toBe("secret"); - }), - ); - - it.effect("exposes source binding helpers over the source facade", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - yield* setSecret(userExecutor, harness.scopes.userWorkspaceA.id, "api-token", "sk-user-a"); - const binding = yield* userExecutor.sources.setBinding({ - scope: harness.scopes.userWorkspaceA.id, - source: { - id: TEST_SOURCE_ID, - scope: harness.scopes.org.id, - }, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("api-token") }, - }); - - const listed = yield* userExecutor.sources.listBindings({ - source: { - id: TEST_SOURCE_ID, - scope: harness.scopes.org.id, - }, - }); - const resolved = yield* userExecutor.sources.resolveBinding({ - source: { - id: TEST_SOURCE_ID, - scope: harness.scopes.org.id, - }, - slotKey: TEST_SLOT, - }); - - expect(listed.map((row) => row.id)).toEqual([binding.id]); - expect(resolved?.id).toBe(binding.id); - expect(resolved?.value).toEqual({ - kind: "secret", - secretId: SecretId.make("api-token"), - secretScopeId: harness.scopes.userWorkspaceA.id, - }); - }), - ); - - it.effect("workspace credential bindings shadow org bindings without copying the source", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - yield* setSecret(orgExecutor, harness.scopes.org.id, "api-token", "sk-org"); - yield* orgExecutor.credentialBindings.set({ - targetScope: harness.scopes.org.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("api-token") }, - }); - - const workspaceExecutor = yield* harness.create([ - harness.scopes.workspace, - harness.scopes.org, - ]); - yield* setSecret(workspaceExecutor, harness.scopes.workspace.id, "api-token", "sk-workspace"); - yield* workspaceExecutor.credentialBindings.set({ - targetScope: harness.scopes.workspace.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("api-token") }, - }); - - const resolved = yield* workspaceExecutor.credentialBindings.resolve({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - }); - const sources = yield* workspaceExecutor.sources.list(); - - expect(resolved.bindingScopeId).toBe(harness.scopes.workspace.id); - expect(sources.filter((source) => source.id === TEST_SOURCE_ID)).toHaveLength(1); - expect(sources.find((source) => source.id === TEST_SOURCE_ID)?.scopeId).toBe( - harness.scopes.org.id, - ); - }), - ); - - it.effect("rejects credential binding writes outside the active scope stack", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - yield* setSecret(userExecutor, harness.scopes.userWorkspaceA.id, "api-token", "sk-user-a"); - - const error = yield* userExecutor.credentialBindings - .set({ - targetScope: ScopeId.make("other-org"), - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("api-token") }, - }) - .pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StorageError")).toBe(true); - }), - ); - - it.effect("rejects binding a user-owned source to an outer-scope credential", () => - Effect.gen(function* () { - const harness = makeHarness(); - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.workspace, - harness.scopes.org, - ]); - yield* userExecutor.credentialTest.registerSource(harness.scopes.userWorkspaceA.id); - yield* setSecret(userExecutor, harness.scopes.org.id, "api-token", "sk-org"); - - const error = yield* userExecutor.credentialBindings - .set({ - targetScope: harness.scopes.org.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.userWorkspaceA.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("api-token") }, - }) - .pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StorageError")).toBe(true); - }), - ); - - it.effect("rejects replacing bindings for a user-owned source at an outer scope", () => - Effect.gen(function* () { - const harness = makeHarness(); - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.workspace, - harness.scopes.org, - ]); - yield* userExecutor.credentialTest.registerSource(harness.scopes.userWorkspaceA.id); - - const error = yield* userExecutor.credentialBindings - .replaceForSource({ - targetScope: harness.scopes.org.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.userWorkspaceA.id, - slotPrefixes: ["request.header."], - bindings: [], - }) - .pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StorageError")).toBe(true); - }), - ); - - it.effect("ignores pre-existing outer-scope bindings for an inner source", () => - Effect.gen(function* () { - const harness = makeHarness(); - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.workspace, - harness.scopes.org, - ]); - yield* userExecutor.credentialTest.registerSource(harness.scopes.userWorkspaceA.id); - yield* setSecret(userExecutor, harness.scopes.org.id, "api-token", "sk-org"); - - const migratedAt = new Date("2026-05-01T00:00:00.000Z"); - yield* Effect.promise(() => - harness.dbFor([harness.scopes.org]).create("credential_binding", { - id: "invalid-outer-binding", - scope_id: harness.scopes.org.id, - plugin_id: TEST_PLUGIN_ID, - source_id: TEST_SOURCE_ID, - source_scope_id: harness.scopes.userWorkspaceA.id, - slot_key: TEST_SLOT, - kind: "secret", - text_value: undefined, - secret_id: "api-token", - connection_id: undefined, - created_at: migratedAt, - updated_at: migratedAt, - }), - ); - - const resolved = yield* userExecutor.credentialBindings.resolve({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.userWorkspaceA.id, - slotKey: TEST_SLOT, - }); - const listed = yield* userExecutor.credentialBindings.listForSource({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.userWorkspaceA.id, - }); - - expect(resolved.status).toBe("missing"); - expect(listed).toEqual([]); - }), - ); - - it.effect("rejects credential binding removals outside the active source scope stack", () => - Effect.gen(function* () { - const harness = makeHarness(); - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - - const error = yield* userExecutor.credentialBindings - .remove({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.userWorkspaceB.id, - slotKey: TEST_SLOT, - }) - .pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StorageError")).toBe(true); - }), - ); - - it.effect("rejects credential binding removals for sources not visible at the given scope", () => - Effect.gen(function* () { - const harness = makeHarness(); - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - - const error = yield* userExecutor.credentialBindings - .remove({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - }) - .pipe(Effect.flip); - - expect(Predicate.isTagged(error, "StorageError")).toBe(true); - }), - ); - - it.effect("secret usages only report credential bindings visible to the caller", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userA = yield* harness.create([harness.scopes.userWorkspaceA, harness.scopes.org]); - yield* setSecret(userA, harness.scopes.userWorkspaceA.id, "shared-token-id", "sk-user-a"); - yield* userA.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("shared-token-id") }, - }); - - const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]); - yield* setSecret(userB, harness.scopes.userWorkspaceB.id, "shared-token-id", "sk-user-b"); - - const userAUsages = yield* userA.secrets.usages(SecretId.make("shared-token-id")); - const userBUsages = yield* userB.secrets.usages(SecretId.make("shared-token-id")); - - expect(userAUsages).toHaveLength(1); - expect(userAUsages[0]).toMatchObject({ - pluginId: TEST_PLUGIN_ID, - scopeId: harness.scopes.userWorkspaceA.id, - ownerId: TEST_SOURCE_ID, - ownerName: "Shared API", - slot: TEST_SLOT, - }); - expect(userBUsages).toEqual([]); - }), - ); - - it.effect("connection usages only report credential bindings visible to the caller", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userA = yield* harness.create([harness.scopes.userWorkspaceA, harness.scopes.org]); - yield* createConnection(userA, harness.scopes.userWorkspaceA.id, "oauth-connection"); - yield* userA.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: "oauth.connection", - value: { - kind: "connection", - connectionId: ConnectionId.make("oauth-connection"), - }, - }); - - const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]); - yield* createConnection(userB, harness.scopes.userWorkspaceB.id, "oauth-connection"); - - const userAUsages = yield* userA.connections.usages(ConnectionId.make("oauth-connection")); - const userBUsages = yield* userB.connections.usages(ConnectionId.make("oauth-connection")); - - expect(userAUsages).toHaveLength(1); - expect(userAUsages[0]).toMatchObject({ - pluginId: TEST_PLUGIN_ID, - scopeId: harness.scopes.userWorkspaceA.id, - ownerId: TEST_SOURCE_ID, - slot: "oauth.connection", - }); - expect(userBUsages).toEqual([]); - }), - ); - - it.effect("source-owner cleanup removes descendant user credential bindings explicitly", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userA = yield* harness.create([harness.scopes.userWorkspaceA, harness.scopes.org]); - yield* setSecret(userA, harness.scopes.userWorkspaceA.id, "alice-token", "sk-user-a"); - yield* userA.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("alice-token") }, - }); - - const userB = yield* harness.create([harness.scopes.userWorkspaceB, harness.scopes.org]); - yield* setSecret(userB, harness.scopes.userWorkspaceB.id, "bob-token", "sk-user-b"); - yield* userB.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceB.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("bob-token") }, - }); - - yield* orgExecutor.credentialBindings.removeForSource({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - }); - - const userAResolved = yield* userA.credentialBindings.resolve({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - }); - const userBResolved = yield* userB.credentialBindings.resolve({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - }); - - expect(userAResolved.status).toBe("missing"); - expect(userBResolved.status).toBe("missing"); - }), - ); - - it.effect("set replaces migrated bindings by natural slot identity", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - yield* setSecret(userExecutor, harness.scopes.userWorkspaceA.id, "old-token", "sk-old"); - yield* setSecret(userExecutor, harness.scopes.userWorkspaceA.id, "new-token", "sk-new"); - - const migratedAt = new Date("2026-05-01T00:00:00.000Z"); - yield* Effect.promise(() => - harness.dbFor([harness.scopes.userWorkspaceA]).create("credential_binding", { - id: "openapi-source-binding:legacy-row-id", - scope_id: harness.scopes.userWorkspaceA.id, - plugin_id: TEST_PLUGIN_ID, - source_id: TEST_SOURCE_ID, - source_scope_id: harness.scopes.org.id, - slot_key: TEST_SLOT, - kind: "secret", - text_value: undefined, - secret_id: "old-token", - connection_id: undefined, - created_at: migratedAt, - updated_at: migratedAt, - }), - ); - - const updated = yield* userExecutor.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("new-token") }, - }); - - const rawRows = yield* Effect.promise(() => - harness.dbFor([harness.scopes.userWorkspaceA]).findMany("credential_binding", { - where: (b) => - b.and( - b("scope_id", "=", harness.scopes.userWorkspaceA.id), - b("plugin_id", "=", TEST_PLUGIN_ID), - b("source_id", "=", TEST_SOURCE_ID), - b("source_scope_id", "=", harness.scopes.org.id), - b("slot_key", "=", TEST_SLOT), - ), - }), - ); - - expect(rawRows).toHaveLength(1); - expect(rawRows[0]?.id).toBe(updated.id); - expect(rawRows[0]?.secret_id).toBe("new-token"); - }), - ); - - it.effect("removing a same-id user secret is blocked when a user binding uses that row", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org"); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - yield* setSecret( - userExecutor, - harness.scopes.userWorkspaceA.id, - "shared-token-id", - "sk-user-a", - ); - yield* userExecutor.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make("shared-token-id") }, - }); - - const result = yield* Effect.result( - userExecutor.secrets.remove( - RemoveSecretInput.make({ - id: SecretId.make("shared-token-id"), - targetScope: harness.scopes.userWorkspaceA.id, - }), - ), - ); - - expect(Result.isFailure(result)).toBe(true); - if (!Result.isFailure(result)) return; - expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true); - }), - ); - - it.effect("a personal binding can point at an organization-owned secret", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org"); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - - const binding = yield* userExecutor.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { - kind: "secret", - secretId: SecretId.make("shared-token-id"), - secretScopeId: harness.scopes.org.id, - }, - }); - - expect(binding.scopeId).toBe(harness.scopes.userWorkspaceA.id); - expect(binding.value).toMatchObject({ - kind: "secret", - secretId: SecretId.make("shared-token-id"), - secretScopeId: harness.scopes.org.id, - }); - - const resolved = yield* userExecutor.credentialBindings.resolve({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - }); - - expect(resolved.status).toBe("resolved"); - expect(resolved.bindingScopeId).toBe(harness.scopes.userWorkspaceA.id); - }), - ); - - it.effect( - "removing an organization secret is blocked when a personal binding references it", - () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - yield* setSecret(orgExecutor, harness.scopes.org.id, "shared-token-id", "sk-org"); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - yield* userExecutor.credentialBindings.set({ - targetScope: harness.scopes.userWorkspaceA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { - kind: "secret", - secretId: SecretId.make("shared-token-id"), - secretScopeId: harness.scopes.org.id, - }, - }); - - const result = yield* Effect.result( - userExecutor.secrets.remove( - RemoveSecretInput.make({ - id: SecretId.make("shared-token-id"), - targetScope: harness.scopes.org.id, - }), - ), - ); - - expect(Result.isFailure(result)).toBe(true); - if (!Result.isFailure(result)) return; - expect(Predicate.isTagged("SecretInUseError")(result.failure)).toBe(true); - }), - ); - - it.effect("rejects an organization binding to a personal secret", () => - Effect.gen(function* () { - const harness = makeHarness(); - const orgExecutor = yield* harness.create([harness.scopes.org]); - yield* orgExecutor.credentialTest.registerSource(harness.scopes.org.id); - - const userExecutor = yield* harness.create([ - harness.scopes.userWorkspaceA, - harness.scopes.org, - ]); - yield* setSecret( - userExecutor, - harness.scopes.userWorkspaceA.id, - "personal-token", - "sk-user-a", - ); - - const result = yield* Effect.result( - userExecutor.credentialBindings.set({ - targetScope: harness.scopes.org.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: harness.scopes.org.id, - slotKey: TEST_SLOT, - value: { - kind: "secret", - secretId: SecretId.make("personal-token"), - secretScopeId: harness.scopes.userWorkspaceA.id, - }, - }), - ); - - expect(Result.isFailure(result)).toBe(true); - if (!Result.isFailure(result)) return; - expect(Predicate.isTagged("StorageError")(result.failure)).toBe(true); - }), - ); - - it.effect( - "materializes a routing row for read-only provider items (e.g. 1Password) when binding", - () => - Effect.gen(function* () { - // Read-only provider that lists an item nobody registered via - // secrets.set — models 1Password / env / file-secrets. - const itemId = "op-vault-item-1"; - const itemName = "Cloudflare API Token"; - const readonlyProvider: SecretProvider = { - key: "readonly-vault", - writable: false, - allowFallback: false, - get: (id) => Effect.sync(() => (id === itemId ? "from-vault" : null)), - list: () => Effect.sync(() => [{ id: itemId, name: itemName }]), - }; - - const scopes = { - org: scope("org", "Org"), - userA: scope("user-workspace-a", "User A Workspace"), - }; - const plugins = [ - memorySecretsPlugin(readonlyProvider), - memoryConnectionPlugin(), - credentialTestPlugin(), - ] as const; - const config = makeTestConfig({ plugins }); - const orgExecutor = yield* createExecutor({ - ...config, - scopes: [scopes.org], - plugins, - onElicitation: "accept-all", - }); - yield* orgExecutor.credentialTest.registerSource(scopes.org.id); - - const userExecutor = yield* createExecutor({ - ...config, - scopes: [scopes.userA, scopes.org], - plugins, - onElicitation: "accept-all", - }); - - const binding = yield* userExecutor.credentialBindings.set({ - targetScope: scopes.userA.id, - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: scopes.org.id, - slotKey: TEST_SLOT, - value: { kind: "secret", secretId: SecretId.make(itemId) }, - }); - - expect(binding.scopeId).toBe(scopes.userA.id); - - const secrets = yield* userExecutor.secrets.list(); - const materialized = secrets.find((s) => String(s.id) === itemId); - expect(materialized).toBeDefined(); - expect(materialized?.provider).toBe("readonly-vault"); - expect(materialized?.name).toBe(itemName); - - const resolved = yield* userExecutor.credentialBindings.resolve({ - pluginId: TEST_PLUGIN_ID, - sourceId: TEST_SOURCE_ID, - sourceScope: scopes.org.id, - slotKey: TEST_SLOT, - }); - expect(resolved.status).toBe("resolved"); - }), - ); -}); diff --git a/packages/core/sdk/src/credential-bindings.ts b/packages/core/sdk/src/credential-bindings.ts deleted file mode 100644 index 2f50f0bd3..000000000 --- a/packages/core/sdk/src/credential-bindings.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Effect, Match, Schema } from "effect"; - -import type { StorageFailure } from "./fuma-runtime"; - -import { credentialBindingKinds, type CredentialBindingRow } from "./core-schema"; -import { ConnectionId, CredentialBindingId, ScopeId, SecretId } from "./ids"; -import type { Usage } from "./usages"; - -export const CredentialBindingKind = Schema.Literals(credentialBindingKinds); -export type CredentialBindingKind = typeof CredentialBindingKind.Type; - -export const CredentialBindingValue = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal("text"), - text: Schema.String, - }), - Schema.Struct({ - kind: Schema.Literal("secret"), - secretId: SecretId, - secretScopeId: Schema.optional(ScopeId), - }), - Schema.Struct({ - kind: Schema.Literal("connection"), - connectionId: ConnectionId, - }), -]); -export type CredentialBindingValue = typeof CredentialBindingValue.Type; - -export const ConfiguredCredentialBinding = Schema.Struct({ - kind: Schema.Literal("binding"), - slot: Schema.String, - prefix: Schema.optional(Schema.String), -}); -export type ConfiguredCredentialBinding = typeof ConfiguredCredentialBinding.Type; - -export const ConfiguredCredentialValue = Schema.Union([Schema.String, ConfiguredCredentialBinding]); -export type ConfiguredCredentialValue = typeof ConfiguredCredentialValue.Type; - -export const ScopedSecretCredentialInput = Schema.Struct({ - secretId: Schema.String, - prefix: Schema.optional(Schema.String), - targetScope: ScopeId, - secretScopeId: Schema.optional(ScopeId), -}); -export type ScopedSecretCredentialInput = typeof ScopedSecretCredentialInput.Type; - -export const CredentialBindingRef = Schema.Struct({ - id: CredentialBindingId, - scopeId: ScopeId, - pluginId: Schema.String, - sourceId: Schema.String, - sourceScopeId: ScopeId, - slotKey: Schema.String, - value: CredentialBindingValue, - createdAt: Schema.Date, - updatedAt: Schema.Date, -}); -export type CredentialBindingRef = typeof CredentialBindingRef.Type; - -export type SetPluginCredentialBindingInput = { - readonly targetScope: ScopeId; - readonly pluginId: string; - readonly sourceId: string; - readonly sourceScope: ScopeId; - readonly slotKey: string; - readonly value: CredentialBindingValue; -}; - -export const CredentialBindingSourceInput = Schema.Struct({ - pluginId: Schema.String, - sourceId: Schema.String, - sourceScope: ScopeId, -}); -export type CredentialBindingSourceInput = typeof CredentialBindingSourceInput.Type; - -export const CredentialBindingSlotInput = Schema.Struct({ - pluginId: Schema.String, - sourceId: Schema.String, - sourceScope: ScopeId, - slotKey: Schema.String, -}); -export type CredentialBindingSlotInput = typeof CredentialBindingSlotInput.Type; - -export const RemoveCredentialBindingInput = Schema.Struct({ - targetScope: ScopeId, - pluginId: Schema.String, - sourceId: Schema.String, - sourceScope: ScopeId, - slotKey: Schema.String, -}); -export type RemoveCredentialBindingInput = typeof RemoveCredentialBindingInput.Type; - -export const ReplaceCredentialBindingValue = Schema.Struct({ - slotKey: Schema.String, - value: CredentialBindingValue, -}); -export type ReplaceCredentialBindingValue = typeof ReplaceCredentialBindingValue.Type; - -export const ReplaceCredentialBindingsInput = Schema.Struct({ - targetScope: ScopeId, - pluginId: Schema.String, - sourceId: Schema.String, - sourceScope: ScopeId, - slotPrefixes: Schema.Array(Schema.String), - bindings: Schema.Array(ReplaceCredentialBindingValue), -}); -export type ReplaceCredentialBindingsInput = typeof ReplaceCredentialBindingsInput.Type; - -export const SourceCredentialBindingSource = Schema.Struct({ - id: Schema.String, - scope: ScopeId, -}); -export type SourceCredentialBindingSource = typeof SourceCredentialBindingSource.Type; - -export const SourceCredentialBindingSourceInput = Schema.Struct({ - source: SourceCredentialBindingSource, -}); -export type SourceCredentialBindingSourceInput = typeof SourceCredentialBindingSourceInput.Type; - -export const SourceCredentialBindingSlotInput = Schema.Struct({ - source: SourceCredentialBindingSource, - slotKey: Schema.String, -}); -export type SourceCredentialBindingSlotInput = typeof SourceCredentialBindingSlotInput.Type; - -export const SetSourceCredentialBindingInput = Schema.Struct({ - scope: ScopeId, - source: SourceCredentialBindingSource, - slotKey: Schema.String, - value: CredentialBindingValue, -}); -export type SetSourceCredentialBindingInput = typeof SetSourceCredentialBindingInput.Type; - -export const RemoveSourceCredentialBindingInput = Schema.Struct({ - scope: ScopeId, - source: SourceCredentialBindingSource, - slotKey: Schema.String, -}); -export type RemoveSourceCredentialBindingInput = typeof RemoveSourceCredentialBindingInput.Type; - -export const ReplaceSourceCredentialBindingsInput = Schema.Struct({ - scope: ScopeId, - source: SourceCredentialBindingSource, - slotPrefixes: Schema.Array(Schema.String), - bindings: Schema.Array(ReplaceCredentialBindingValue), -}); -export type ReplaceSourceCredentialBindingsInput = typeof ReplaceSourceCredentialBindingsInput.Type; - -export const CredentialBindingResolutionStatus = Schema.Literals([ - "resolved", - "missing", - "blocked", -]); -export type CredentialBindingResolutionStatus = typeof CredentialBindingResolutionStatus.Type; - -export const ResolvedCredentialSlot = Schema.Struct({ - pluginId: Schema.String, - sourceId: Schema.String, - sourceScopeId: ScopeId, - slotKey: Schema.String, - bindingScopeId: Schema.NullOr(ScopeId), - kind: Schema.NullOr(CredentialBindingKind), - status: CredentialBindingResolutionStatus, -}); -export type ResolvedCredentialSlot = typeof ResolvedCredentialSlot.Type; - -export interface CredentialBindingsFacade { - readonly listForSource: ( - input: CredentialBindingSourceInput, - ) => Effect.Effect; - readonly resolveBinding: ( - input: CredentialBindingSlotInput, - ) => Effect.Effect; - readonly resolve: ( - input: CredentialBindingSlotInput, - ) => Effect.Effect; - readonly set: ( - input: SetPluginCredentialBindingInput, - ) => Effect.Effect; - readonly remove: (input: RemoveCredentialBindingInput) => Effect.Effect; - readonly replaceForSource: ( - input: ReplaceCredentialBindingsInput, - ) => Effect.Effect; - readonly removeForSource: ( - input: CredentialBindingSourceInput, - ) => Effect.Effect; - readonly usagesForSecret: (id: string) => Effect.Effect; - readonly usagesForConnection: (id: string) => Effect.Effect; -} - -export const credentialBindingId = (input: { - readonly pluginId: string; - readonly sourceId: string; - readonly sourceScope: string; - readonly slotKey: string; -}): CredentialBindingId => - CredentialBindingId.make( - JSON.stringify([input.pluginId, input.sourceScope, input.sourceId, input.slotKey]), - ); - -export const credentialSlotPart = (value: string): string => - value - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "default"; - -export const credentialSlotKey = (prefix: string, name: string): string => - `${prefix}:${credentialSlotPart(name)}`; - -export const credentialBindingValueFromRow = (row: CredentialBindingRow): CredentialBindingValue => - Match.value(row).pipe( - Match.when({ kind: "text" }, ({ text_value }) => ({ - kind: "text" as const, - text: text_value, - })), - Match.when({ kind: "secret" }, ({ scope_id, secret_id, secret_scope_id }) => ({ - kind: "secret" as const, - secretId: SecretId.make(secret_id), - secretScopeId: ScopeId.make(secret_scope_id ?? scope_id), - })), - Match.when({ kind: "connection" }, ({ connection_id }) => ({ - kind: "connection" as const, - connectionId: ConnectionId.make(connection_id), - })), - Match.exhaustive, - ); - -export const credentialBindingRowToRef = (row: CredentialBindingRow): CredentialBindingRef => { - const value = credentialBindingValueFromRow(row); - return CredentialBindingRef.make({ - id: CredentialBindingId.make(row.id), - scopeId: ScopeId.make(row.scope_id), - pluginId: row.plugin_id, - sourceId: row.source_id, - sourceScopeId: ScopeId.make(row.source_scope_id), - slotKey: row.slot_key, - value, - createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), - updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at), - }); -}; diff --git a/packages/core/sdk/src/elicitation.ts b/packages/core/sdk/src/elicitation.ts index da7ab276d..87b409698 100644 --- a/packages/core/sdk/src/elicitation.ts +++ b/packages/core/sdk/src/elicitation.ts @@ -1,69 +1,65 @@ import { Effect, Schema } from "effect"; -import { ToolId } from "./ids"; +import { ElicitationId, ToolAddress } from "./ids"; -// --------------------------------------------------------------------------- -// Elicitation request — what a tool sends when it needs user input -// --------------------------------------------------------------------------- +/* A tool that needs user input mid-call suspends and the host's `onElicitation` + * handler (executor-level, overridable per `execute`) answers. Tools that never + * elicit never trigger it. Schema-tagged so requests/responses cross the wire. */ -/** Tool needs structured input from the user (render a form) */ +/** Tool needs structured input from the user (render a form). */ export const FormElicitation = Schema.TaggedStruct("FormElicitation", { message: Schema.String, - /** JSON Schema describing the fields to collect */ + /** JSON Schema describing the fields to collect. */ requestedSchema: Schema.Record(Schema.String, Schema.Unknown), }); export type FormElicitation = typeof FormElicitation.Type; -/** Tool needs the user to visit a URL (OAuth, approval page, etc.) */ +/** Tool needs the user to visit a URL (OAuth, approval page, etc.). */ export const UrlElicitation = Schema.TaggedStruct("UrlElicitation", { message: Schema.String, url: Schema.String, - /** Unique ID so the host can correlate the callback */ - elicitationId: Schema.String, + /** Unique id so the host can correlate the callback. */ + elicitationId: ElicitationId, }); export type UrlElicitation = typeof UrlElicitation.Type; export type ElicitationRequest = FormElicitation | UrlElicitation; -// --------------------------------------------------------------------------- -// Elicitation response — what the host sends back -// --------------------------------------------------------------------------- - export const ElicitationAction = Schema.Literals(["accept", "decline", "cancel"]); export type ElicitationAction = typeof ElicitationAction.Type; export const ElicitationResponse = Schema.Struct({ action: ElicitationAction, - /** Present when action is "accept" — the data the user provided */ + /** Present when `action` is "accept" — the data the user provided. */ content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }); export type ElicitationResponse = typeof ElicitationResponse.Type; -// --------------------------------------------------------------------------- -// Elicitation handler — the host provides this to handle requests -// --------------------------------------------------------------------------- - +/** Handler input — the tool address being invoked, its args, and the request. */ export interface ElicitationContext { - readonly toolId: ToolId; + readonly address: ToolAddress; readonly args: unknown; readonly request: ElicitationRequest; } -/** - * A function the host provides to handle elicitation. - * The SDK calls this when a tool suspends to ask for user input. - * The host renders UI / prompts the user / does OAuth / etc. - */ +/** Host-provided handler the SDK calls when a tool suspends for input. */ export type ElicitationHandler = (ctx: ElicitationContext) => Effect.Effect; -// --------------------------------------------------------------------------- -// Elicitation error — tool was declined or cancelled -// --------------------------------------------------------------------------- +/** Executor-level elicitation policy: a handler, or `"accept-all"` to + * auto-accept every request (tests / non-interactive hosts). */ +export type OnElicitation = ElicitationHandler | "accept-all"; + +/** Per-call options for `execute`. */ +export interface InvokeOptions { + /** Override the executor-level handler for this single call. */ + readonly onElicitation?: OnElicitation; +} +/** A tool was declined or cancelled during elicitation. */ export class ElicitationDeclinedError extends Schema.TaggedErrorClass()( "ElicitationDeclinedError", { - toolId: ToolId, + address: ToolAddress, action: Schema.Literals(["decline", "cancel"]), }, ) {} diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index 59efd95a5..a19d6cc62 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -1,6 +1,13 @@ -import { Data, Schema } from "effect"; +import { Schema } from "effect"; -import { ConnectionId, ToolId, SecretId } from "./ids"; +import { ElicitationDeclinedError } from "./elicitation"; +import type { StorageFailure } from "./fuma-runtime"; +import { ConnectionName, IntegrationSlug, Owner, ProviderKey, ToolAddress } from "./ids"; + +/* The failure set the SDK surfaces. `execute`'s invoke failures are ported from + * v1 but re-keyed by `address` (the full `tools....` + * handle) instead of an opaque tool id. Storage failures reuse fuma-runtime's + * `StorageError`/`UniqueViolationError` (`StorageFailure`) — not redefined here. */ // --------------------------------------------------------------------------- // Tool lifecycle @@ -9,182 +16,131 @@ import { ConnectionId, ToolId, SecretId } from "./ids"; export class ToolNotFoundError extends Schema.TaggedErrorClass()( "ToolNotFoundError", { - toolId: ToolId, - suggestions: Schema.optional(Schema.Array(ToolId)), + address: ToolAddress, + suggestions: Schema.optional(Schema.Array(ToolAddress)), }, ) {} -export class ToolInvocationError extends Data.TaggedError("ToolInvocationError")<{ - readonly toolId: ToolId; - readonly message: string; - readonly cause?: unknown; -}> {} - -/** Tool row exists in the DB but its owning plugin isn't loaded. Means - * the tool was registered by a plugin that's no longer present in the - * current executor config — usually a stale row from an older session. */ -export class PluginNotLoadedError extends Schema.TaggedErrorClass()( - "PluginNotLoadedError", +export class ToolInvocationError extends Schema.TaggedErrorClass()( + "ToolInvocationError", { - pluginId: Schema.String, - toolId: ToolId, + address: ToolAddress, + message: Schema.String, + cause: Schema.optional(Schema.Unknown), }, ) {} -/** Tool was found but its owning plugin has no `invokeTool` handler — - * the plugin only declares static tools and this one's id matched - * dynamically somehow. Shouldn't happen in practice; guards against - * programmer error. */ -export class NoHandlerError extends Schema.TaggedErrorClass()("NoHandlerError", { - toolId: ToolId, - pluginId: Schema.String, -}) {} - -/** Tool invocation was rejected because a workspace `tool_policy` rule - * with `action: "block"` matched. `pattern` is the matched policy - * pattern so callers / agents can render a useful "this is blocked - * by your `vercel.dns.*` rule" message. */ +/** Tool invocation was rejected because a workspace `tool_policy` rule with + * `action: "block"` matched. `pattern` is the matched policy pattern. */ export class ToolBlockedError extends Schema.TaggedErrorClass()( "ToolBlockedError", { - toolId: ToolId, + address: ToolAddress, pattern: Schema.String, }, ) {} -// --------------------------------------------------------------------------- -// Source lifecycle -// --------------------------------------------------------------------------- - -export class SourceNotFoundError extends Schema.TaggedErrorClass()( - "SourceNotFoundError", - { sourceId: Schema.String }, +/** Tool row exists but its owning plugin isn't loaded in this executor config. */ +export class PluginNotLoadedError extends Schema.TaggedErrorClass()( + "PluginNotLoadedError", + { + address: ToolAddress, + pluginId: Schema.String, + }, ) {} -/** `executor.sources.remove({ id, targetScope })` was called on a - * source with `canRemove: false` — typically a static source declared - * by a plugin at startup. Removing static sources is a bug in the - * caller. */ -export class SourceRemovalNotAllowedError extends Schema.TaggedErrorClass()( - "SourceRemovalNotAllowedError", - { sourceId: Schema.String }, -) {} +/** Tool was found but its owning plugin has no `invokeTool` handler. */ +export class NoHandlerError extends Schema.TaggedErrorClass()("NoHandlerError", { + address: ToolAddress, + pluginId: Schema.String, +}) {} // --------------------------------------------------------------------------- -// Secrets +// Integration / connection lifecycle // --------------------------------------------------------------------------- -export class SecretNotFoundError extends Schema.TaggedErrorClass()( - "SecretNotFoundError", - { secretId: SecretId }, +export class IntegrationNotFoundError extends Schema.TaggedErrorClass()( + "IntegrationNotFoundError", + { slug: IntegrationSlug }, ) {} -export class SecretResolutionError extends Schema.TaggedErrorClass()( - "SecretResolutionError", - { - secretId: SecretId, - message: Schema.String, - }, -) {} - -/** Raised when `secrets.remove({ id, targetScope })` is called on a secret whose row has - * `owned_by_connection_id` set. The connection owns the lifecycle — - * callers must go through `connections.remove(connectionId)` to - * delete it along with its siblings. */ -export class SecretOwnedByConnectionError extends Schema.TaggedErrorClass()( - "SecretOwnedByConnectionError", - { - secretId: SecretId, - connectionId: ConnectionId, - }, +/** An "add integration" operation targeted a slug (namespace) that is already + * registered. The core `integrations.register` primitive upserts by design + * (for idempotent boot re-registration); add-operation layers gate on this to + * prevent silently clobbering an existing integration's tools, connections, + * and policies. */ +export class IntegrationAlreadyExistsError extends Schema.TaggedErrorClass()( + "IntegrationAlreadyExistsError", + { slug: IntegrationSlug }, + { httpApiStatus: 409 }, ) {} -/** Raised when `secrets.remove({ id, targetScope })` is called on a secret that's still - * referenced by one or more sources / bindings across plugins. The UI's - * "Used by" list tells the user which sources to detach first. App- - * level RESTRICT — the codebase doesn't enforce DB-level FKs because - * composite `(scope_id, id)` PKs make single-column references - * impossible to constrain in sqlite. `usageCount` is a hint for the - * caller; the full list is queryable via `secrets.usages(id)`. */ -export class SecretInUseError extends Schema.TaggedErrorClass()( - "SecretInUseError", - { - secretId: SecretId, - usageCount: Schema.Number, - }, +/** `integrations.remove` was called on an integration declared statically by a + * plugin at startup (`canRemove: false`). */ +export class IntegrationRemovalNotAllowedError extends Schema.TaggedErrorClass()( + "IntegrationRemovalNotAllowedError", + { slug: IntegrationSlug }, ) {} -// --------------------------------------------------------------------------- -// Connections -// --------------------------------------------------------------------------- - export class ConnectionNotFoundError extends Schema.TaggedErrorClass()( "ConnectionNotFoundError", - { connectionId: ConnectionId }, -) {} - -export class ConnectionProviderNotRegisteredError extends Schema.TaggedErrorClass()( - "ConnectionProviderNotRegisteredError", { - provider: Schema.String, - connectionId: Schema.optional(ConnectionId), + owner: Owner, + integration: IntegrationSlug, + name: ConnectionName, }, ) {} -export class ConnectionRefreshNotSupportedError extends Schema.TaggedErrorClass()( - "ConnectionRefreshNotSupportedError", - { - connectionId: ConnectionId, - provider: Schema.String, - }, +/** A connection create request was rejected before anything was written: the + * input is structurally invalid (no credential inputs for a credentialed + * template, mixed pasted/external origins, …) or targets owner `user` in a + * context that has no user subject. The message says which — it is safe to + * show to the caller. */ +export class InvalidConnectionInputError extends Schema.TaggedErrorClass()( + "InvalidConnectionInputError", + { message: Schema.String }, ) {} -/** - * Raised by `connections.accessToken(id)` when the provider's refresh - * handler reported that the stored refresh token is permanently - * invalid (RFC 6749 §5.2 `invalid_grant` and friends). The caller — - * typically a tool invocation — surfaces this so the UI can prompt the - * user to sign in again. Distinct from `ConnectionRefreshError` so - * "the network flaked, retry later" and "the grant is dead, re-auth" - * don't collapse into one error tag at the plugin boundary. - */ -export class ConnectionReauthRequiredError extends Schema.TaggedErrorClass()( - "ConnectionReauthRequiredError", - { - connectionId: ConnectionId, - provider: Schema.String, - message: Schema.String, - }, +/** A connection references a credential provider key that isn't registered on + * the executor. */ +export class CredentialProviderNotRegisteredError extends Schema.TaggedErrorClass()( + "CredentialProviderNotRegisteredError", + { provider: ProviderKey }, ) {} -/** Raised when `connections.remove(id)` is called on a connection that's - * still referenced by sources / bindings. Mirrors `SecretInUseError`. */ -export class ConnectionInUseError extends Schema.TaggedErrorClass()( - "ConnectionInUseError", +/** A connection's value could not be resolved — the provider returned nothing, + * or an OAuth token refresh failed and the user must re-auth. */ +export class CredentialResolutionError extends Schema.TaggedErrorClass()( + "CredentialResolutionError", { - connectionId: ConnectionId, - usageCount: Schema.Number, + owner: Owner, + integration: IntegrationSlug, + name: ConnectionName, + message: Schema.String, + /** True when the stored grant is permanently invalid and the user must + * sign in again (RFC 6749 §5.2 invalid_grant and friends). */ + reauthRequired: Schema.optional(Schema.Boolean), }, ) {} // --------------------------------------------------------------------------- -// Union type for convenience in signatures. +// Union — the failure channel of `execute`. // --------------------------------------------------------------------------- -export type ExecutorError = +export type ExecuteError = | ToolNotFoundError | ToolInvocationError + | ToolBlockedError | PluginNotLoadedError | NoHandlerError - | ToolBlockedError - | SourceNotFoundError - | SourceRemovalNotAllowedError - | SecretNotFoundError - | SecretResolutionError - | SecretOwnedByConnectionError - | SecretInUseError | ConnectionNotFoundError - | ConnectionProviderNotRegisteredError - | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError - | ConnectionInUseError; + | CredentialProviderNotRegisteredError + | CredentialResolutionError + | ElicitationDeclinedError + | StorageFailure; + +/** Convenience union spanning every typed error the SDK raises. */ +export type ExecutorError = + | ExecuteError + | IntegrationNotFoundError + | IntegrationRemovalNotAllowedError; diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 48d2c95fd..2c68fe529 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -1,75 +1,110 @@ import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Predicate, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; +import { Data, Effect, Predicate, Result } from "effect"; -import { ElicitationResponse } from "./elicitation"; import { ToolNotFoundError } from "./errors"; -import { createExecutor } from "./executor"; -import { ScopeId, SecretId } from "./ids"; -import { definePlugin } from "./plugin"; -import { Scope } from "./scope"; -import { SourceDetectionResult } from "./types"; import { - makeTestConfig, - makeTestExecutor, - memorySecretsPlugin, - serveOAuthTestServer, -} from "./testing"; + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ProviderItemId, + ProviderKey, + ToolAddress, + ToolName, +} from "./ids"; +import { definePlugin } from "./plugin"; +import type { CredentialProvider } from "./provider"; +import { IntegrationDetectionResult } from "./types"; +import { makeTestExecutor } from "./testing"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +// removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id +// resolution, secrets/sources/scope-stack. The integration coverage below is +// ported to the v2 surface (integrations/connections/OAuth/resolveTools/execute/ +// tools.schema). class TestPluginError extends Data.TaggedError("TestPluginError")<{ readonly message: string; }> {} -const testScope = Scope.make({ - id: ScopeId.make("test-scope"), - name: "test", - createdAt: new Date(), -}); - -const txPlugin = definePlugin(() => ({ - id: "tx" as const, +const memoryProvider = (): CredentialProvider => { + const store = new Map(); + return { + key: ProviderKey.make("memory"), + writable: true, + get: (id) => Effect.sync(() => store.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void store.set(String(id), value)), + }; +}; + +const INTEG = IntegrationSlug.make("demo"); +const TEMPLATE = AuthTemplateSlug.make("apiKey"); +const CONN = ConnectionName.make("main"); + +const addr = (tool: string): ToolAddress => ToolAddress.make(`tools.${INTEG}.org.${CONN}.${tool}`); + +// --------------------------------------------------------------------------- +// A plugin that registers an integration, produces per-connection tools via +// resolveTools (with shared $defs), and supports ctx.transaction rollback. +// --------------------------------------------------------------------------- + +const demoPlugin = definePlugin(() => ({ + id: "demo" as const, + credentialProviders: [memoryProvider()], storage: ({ pluginStorage }) => ({ - create: (input: { readonly id: string; readonly scope: string; readonly value: string }) => - pluginStorage - .put({ - collection: "item", - key: input.id, - scope: input.scope, - data: { value: input.value }, - }) - .pipe(Effect.asVoid), + put: (owner: "org" | "user", key: string, value: string) => + pluginStorage.put({ collection: "item", key, owner, data: { value } }).pipe(Effect.asVoid), list: () => - pluginStorage.list<{ readonly value: string }>({ collection: "item" }).pipe( - Effect.map((rows) => - rows - .map((row) => ({ - id: row.key, - scope_id: String(row.scopeId), - value: row.data.value, - })) - .sort((a, b) => a.id.localeCompare(b.id)), - ), - ), + pluginStorage + .list<{ readonly value: string }>({ collection: "item" }) + .pipe(Effect.map((rows) => rows.map((row) => ({ id: row.key, value: row.data.value })))), }), + resolveTools: () => + Effect.succeed({ + tools: [ + { + name: ToolName.make("inspect"), + description: "inspect", + inputSchema: { + type: "object", + properties: { pet: { $ref: "#/$defs/Pet" } }, + required: ["pet"], + }, + outputSchema: { $ref: "#/$defs/Owner" }, + }, + { name: ToolName.make("run"), description: "run" }, + ], + definitions: { + Pet: { anyOf: [{ $ref: "#/$defs/Dog" }, { $ref: "#/$defs/Cat" }] }, + Dog: { + type: "object", + properties: { collar: { $ref: "#/$defs/Collar" } }, + }, + Cat: { type: "object", properties: { lives: { type: "number" } } }, + Collar: { type: "object", properties: { id: { type: "string" } } }, + Owner: { type: "object", properties: { pet: { $ref: "#/$defs/Pet" } } }, + Unused: { type: "object", properties: { value: { type: "string" } } }, + }, + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), extension: (ctx) => ({ - seed: (id: string, value: string, scope = String(ctx.scopes[0]!.id)) => - ctx.storage.create({ id, scope, value }), - list: () => ctx.storage.list(), + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Demo", + config: {}, + }), + storagePut: (owner: "org" | "user", key: string, value: string) => + ctx.storage.put(owner, key, value), + storageList: () => ctx.storage.list(), failAfterPluginAndCoreWrites: () => ctx.transaction( Effect.gen(function* () { - const scope = String(ctx.scopes[0]!.id); - yield* ctx.storage.create({ - id: "tx-row", - scope, - value: "created-before-failure", - }); - yield* ctx.core.sources.register({ - id: "tx-source", - scope, - kind: "test", - name: "Tx Source", - tools: [{ name: "run", description: "run" }], + yield* ctx.storage.put("org", "tx-row", "created-before-failure"); + yield* ctx.core.integrations.register({ + slug: IntegrationSlug.make("tx-integration"), + description: "Tx", + config: {}, }); return yield* new TestPluginError({ message: "rollback" }); }), @@ -77,793 +112,361 @@ const txPlugin = definePlugin(() => ({ }), }))(); -const detector = (id: string, confidence: SourceDetectionResult["confidence"]) => +const detector = (id: string, confidence: IntegrationDetectionResult["confidence"]) => definePlugin(() => ({ id, storage: () => ({}), detect: () => Effect.succeed( - SourceDetectionResult.make({ + IntegrationDetectionResult.make({ kind: id, confidence, endpoint: `https://example.com/${id}`, name: id, - namespace: id, + slug: id, }), ), }))(); -const schemaProbePlugin = definePlugin(() => ({ - id: "schemaProbe" as const, - storage: () => ({}), - extension: (ctx) => ({ - registerSource: () => - ctx.transaction( - Effect.gen(function* () { - const scope = String(ctx.scopes[0]!.id); - yield* ctx.core.sources.register({ - id: "schema-source", - scope, - kind: "schema", - name: "Schema Source", - tools: [ - { - name: "inspect", - description: "inspect", - inputSchema: { - type: "object", - properties: { - pet: { $ref: "#/$defs/Pet" }, - }, - required: ["pet"], - }, - outputSchema: { $ref: "#/$defs/Owner" }, - }, - ], - }); - yield* ctx.core.definitions.register({ - sourceId: "schema-source", - scope, - definitions: { - Pet: { - anyOf: [{ $ref: "#/$defs/Dog" }, { $ref: "#/$defs/Cat" }], - }, - Dog: { - type: "object", - properties: { - collar: { $ref: "#/$defs/Collar" }, - }, - }, - Cat: { - type: "object", - properties: { - lives: { type: "number" }, - }, - }, - Collar: { - type: "object", - properties: { - id: { type: "string" }, - }, - }, - Owner: { - type: "object", - properties: { - pet: { $ref: "#/$defs/Pet" }, - }, - }, - Unused: { - type: "object", - properties: { - value: { type: "string" }, - }, - }, - }, - }); - }), - ), - }), -}))(); - -const caseSensitiveDynamicPlugin = definePlugin(() => ({ - id: "caseDynamic" as const, - storage: () => ({}), - extension: (ctx) => ({ - registerSource: () => - ctx.core.sources.register({ - id: "case_source", - scope: String(ctx.scopes[0]!.id), - kind: "case", - name: "Case Source", - tools: [{ name: "listdashboards", description: "list dashboards" }], - }), - }), - invokeTool: ({ toolRow }) => Effect.succeed({ invokedToolId: toolRow.id }), -}))(); - -const configurableSourcePlugin = definePlugin(() => ({ - id: "configurable" as const, - sourcePresets: [ - { - id: "configurable-demo", - name: "Configurable Demo", - summary: "Demo source preset for agent and web discovery.", - url: "https://example.com/configurable.json", - featured: true, - }, - ], - storage: ({ pluginStorage }) => ({ - get: (scope: string, sourceId = "configured-source") => - pluginStorage.getAtScope<{ readonly header: string; readonly sourceScope: string }>({ - scope, - collection: "source-config", - key: sourceId, - }), - visible: (sourceId = "configured-source") => - pluginStorage.get<{ readonly header: string; readonly sourceScope: string }>({ - collection: "source-config", - key: sourceId, - }), - }), - extension: (ctx) => ({ - registerSource: (scope: string) => - ctx.core.sources.register({ - id: "configured-source", - scope, - kind: "configurable", - name: "Configurable Source", - canRemove: true, - tools: [{ name: "run", description: "run configurable source" }], - }), - getConfigAtScope: (scope: string) => ctx.storage.get(scope), - getVisibleConfig: () => ctx.storage.visible(), - }), - sourceConfigure: { - type: "configurable", - schema: Schema.Struct({ header: Schema.String }), - configure: ({ ctx, sourceId, sourceScope, targetScope, config }) => - ctx.pluginStorage.put({ - scope: targetScope, - collection: "source-config", - key: sourceId, - data: { - ...(config as { readonly header: string }), - sourceScope, - }, - }), - }, -}))(); - describe("createExecutor", () => { it.effect("rolls back plugin and core writes from ctx.transaction failures", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor({ plugins: [txPlugin] as const }); - - const error = yield* executor.tx.failAfterPluginAndCoreWrites().pipe(Effect.flip); + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + }); + const result = yield* Effect.result(executor.demo.failAfterPluginAndCoreWrites()); + expect(Result.isFailure(result)).toBe(true); - expect(error).toMatchObject({ _tag: "TestPluginError", message: "rollback" }); - expect(yield* executor.tx.list()).toEqual([]); - expect(yield* executor.sources.list()).toEqual([]); - expect(yield* executor.tools.list()).toEqual([]); + // Neither the plugin row nor the core integration row should survive. + const rows = yield* executor.demo.storageList(); + expect(rows).toEqual([]); + const integrations = yield* executor.integrations.list(); + expect(integrations.map((i) => String(i.slug))).not.toContain("tx-integration"); }), ); - it.effect("runs plugin and database close hooks", () => + it.effect("runs plugin close hooks", () => Effect.gen(function* () { - let pluginClosed = false; - let dbClosed = false; - const closablePlugin = definePlugin(() => ({ - id: "closable" as const, + let closed = false; + const closingPlugin = definePlugin(() => ({ + id: "closing" as const, storage: () => ({}), - close: () => - Effect.sync(() => { - pluginClosed = true; - }), - })); - const config = makeTestConfig({ plugins: [closablePlugin()] as const }); - const executor = yield* createExecutor({ - ...config, - db: { - db: config.db, - close: () => - Effect.sync(() => { - dbClosed = true; - }), - }, - onElicitation: "accept-all", + close: () => Effect.sync(() => void (closed = true)), + }))(); + const executor = yield* makeTestExecutor({ + plugins: [closingPlugin] as const, }); - yield* executor.close(); - - expect(pluginClosed).toBe(true); - expect(dbClosed).toBe(true); - yield* Effect.promise(() => config.testDb.close()); + expect(closed).toBe(true); }), ); - it.effect("orders source detection results by confidence and applies configured bounds", () => + it.effect("projects core tools as the built-in Executor integration", () => Effect.gen(function* () { - const executor = yield* createExecutor({ - ...makeTestConfig({ - plugins: [detector("low", "low"), detector("high", "high"), detector("medium", "medium")], - }), - sourceDetection: { maxDetectors: 2, maxResults: 1 }, - onElicitation: "accept-all", + const executor = yield* makeTestExecutor({ + coreTools: { webBaseUrl: "http://localhost:3000" }, + }); + const integrations = yield* executor.integrations.list(); + const executorIntegration = integrations.find((i) => String(i.slug) === "executor"); + expect(executorIntegration).toMatchObject({ + description: "Executor", + kind: "built-in", + canRemove: false, + canRefresh: false, + }); + + const address = ToolAddress.make("executor.coreTools.integrations.list"); + const tools = yield* executor.tools.list({ + integration: IntegrationSlug.make("executor"), + includeBlocked: true, + }); + const listed = tools.find((toolRow) => toolRow.address === address); + expect(listed).toMatchObject({ + address, + integration: IntegrationSlug.make("executor"), + connection: ConnectionName.make("coreTools"), + name: ToolName.make("coreTools.integrations.list"), + static: true, + }); + + const schema = yield* executor.tools.schema(address); + expect(schema).toMatchObject({ + address, + name: "coreTools.integrations.list", + outputSchema: { + type: "object", + required: ["integrations"], + }, }); - const results = yield* executor.sources.detect("https://example.com/source"); - - expect(results.map((result) => result.kind)).toEqual(["high"]); - }), - ); - - it.effect("applies hosted outbound policy before source detection plugins run", () => - Effect.gen(function* () { - let called = false; - const hostedDetector = definePlugin(() => ({ - id: "hosted-detector" as const, - storage: () => ({}), - detect: () => - Effect.sync(() => { - called = true; - return SourceDetectionResult.make({ - kind: "hosted-detector", - confidence: "high", - endpoint: "http://127.0.0.1/source", - name: "hosted detector", - namespace: "hosted_detector", - }); - }), - })); - const executor = yield* createExecutor({ - scopes: [testScope], - plugins: [hostedDetector()] as const, - httpClientLayer: FetchHttpClient.layer, - onElicitation: "accept-all", + const out = yield* executor.execute(address, {}); + expect(out).toMatchObject({ + integrations: [expect.objectContaining({ slug: "executor" })], }); - - const results = yield* executor.sources.detect("http://127.0.0.1/source"); - - expect(results).toEqual([]); - expect(called).toBe(false); }), ); - it.effect("returns schema roots with shared reachable definitions", () => + it.effect("can omit provider tools from the built-in Executor integration", () => Effect.gen(function* () { - const executor = yield* makeTestExecutor({ plugins: [schemaProbePlugin] as const }); - - yield* executor.schemaProbe.registerSource(); - - const schema = yield* executor.tools.schema("schema-source.inspect"); - - expect(schema?.inputSchema).toEqual({ - type: "object", - properties: { - pet: { $ref: "#/$defs/Pet" }, + const executor = yield* makeTestExecutor({ + coreTools: { + webBaseUrl: "http://localhost:3000", + includeProviders: false, }, - required: ["pet"], - }); - expect(schema?.outputSchema).toEqual({ $ref: "#/$defs/Owner" }); - expect(schema?.schemaDefinitions).toEqual({ - Cat: expect.any(Object), - Collar: expect.any(Object), - Dog: expect.any(Object), - Owner: expect.any(Object), - Pet: expect.any(Object), }); - expect(schema?.schemaDefinitions).not.toHaveProperty("Unused"); - expect(schema?.inputTypeScript).toContain("pet: Pet"); - expect(schema?.outputTypeScript).toBe("Owner"); - expect(schema?.typeScriptDefinitions).toEqual( - expect.objectContaining({ - Pet: expect.any(String), - Owner: expect.any(String), - }), - ); - }), - ); - it.effect("resolves dynamic tool ids case-insensitively before invoking plugins", () => - Effect.gen(function* () { - const executor = yield* makeTestExecutor({ - plugins: [caseSensitiveDynamicPlugin] as const, + const tools = yield* executor.tools.list({ + integration: IntegrationSlug.make("executor"), + includeBlocked: true, }); - yield* executor.caseDynamic.registerSource(); - - const result = yield* executor.tools.invoke("case_source.listDashboards", {}); + const names = tools.map((toolRow) => String(toolRow.name)).sort(); - expect(result).toEqual({ invokedToolId: "case_source.listdashboards" }); + expect(names).toContain("coreTools.integrations.list"); + expect(names).not.toContain("coreTools.providers.list"); + expect(names).not.toContain("coreTools.providers.items"); }), ); - it.effect("applies policies after case-insensitive dynamic tool id resolution", () => + it.effect("creates provider-backed connections through the built-in Executor tools", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ - plugins: [caseSensitiveDynamicPlugin] as const, + plugins: [demoPlugin] as const, + coreTools: { webBaseUrl: "http://localhost:3000" }, }); - yield* executor.caseDynamic.registerSource(); - yield* executor.policies.create({ - targetScope: "test-scope", - pattern: "case_source.listdashboards", - action: "require_approval", - }); - const calls = { count: 0 }; + yield* executor.demo.seed(); - const result = yield* executor.tools.invoke( - "case_source.listDashboards", - {}, + const created = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.create"), { - onElicitation: () => - Effect.sync(() => { - calls.count += 1; - return ElicitationResponse.make({ action: "accept" }); - }), + owner: "org", + name: String(CONN), + integration: String(INTEG), + template: String(TEMPLATE), + identityLabel: "Demo", + from: { provider: "memory", id: "secret-token" }, }, ); + expect(created).toMatchObject({ + owner: "org", + name: String(CONN), + integration: String(INTEG), + template: String(TEMPLATE), + address: "tools.demo.org.main", + identityLabel: "Demo", + oauthClient: null, + }); + + const listed = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.list"), + { integration: String(INTEG), owner: "org" }, + ); + expect(listed).toMatchObject({ + connections: [expect.objectContaining({ address: "tools.demo.org.main" })], + }); - expect(result).toEqual({ invokedToolId: "case_source.listdashboards" }); - expect(calls.count).toBe(1); + const out = yield* executor.execute(addr("run"), {}); + expect(out).toEqual({ ran: "run" }); }), ); - it.effect("suggests visible tools for missing dynamic tool ids", () => + it.effect("hands pasted credential entry to the web UI", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ - plugins: [caseSensitiveDynamicPlugin] as const, + coreTools: { webBaseUrl: "http://localhost:3000" }, }); - yield* executor.caseDynamic.registerSource(); - const error = yield* executor.tools - .invoke("case_source.listDashboardsWRONG", {}) - .pipe(Effect.flip); + const handoff = yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.createHandoff"), + { + integration: String(INTEG), + owner: "user", + template: String(TEMPLATE), + label: "Demo token", + }, + ); - expect(error).toBeInstanceOf(ToolNotFoundError); - if (!Predicate.isTagged("ToolNotFoundError")(error)) return; - expect(error.suggestions).toEqual(["case_source.listdashboards"]); + expect(handoff).toMatchObject({ + instructions: expect.stringContaining("Do not ask them to paste"), + }); + const handoffOutput = handoff as { readonly url: string }; + const url = new URL(handoffOutput.url); + expect(url.origin).toBe("http://localhost:3000"); + expect(url.pathname).toBe(`/integrations/${String(INTEG)}`); + expect(url.searchParams.get("addAccount")).toBe("1"); + expect(url.searchParams.get("owner")).toBe("user"); + expect(url.searchParams.get("template")).toBe(String(TEMPLATE)); + expect(url.searchParams.get("label")).toBe("Demo token"); + expect(url.search).not.toContain("secret"); }), ); - it.effect("dispatches source.configure through the owning plugin with explicit scopes", () => - Effect.gen(function* () { - const orgScope = Scope.make({ - id: ScopeId.make("org"), - name: "Org", - createdAt: new Date(), - }); - const userScope = Scope.make({ - id: ScopeId.make("user"), - name: "User", - createdAt: new Date(), - }); - const executor = yield* createExecutor({ - scopes: [userScope, orgScope], - plugins: [configurableSourcePlugin] as const, - onElicitation: "accept-all", - }); - - yield* executor.configurable.registerSource("org"); - yield* executor.sources.configure({ - source: { id: "configured-source", scope: "org" }, - scope: "org", - type: "configurable", - config: { header: "org-token" }, - }); - yield* executor.sources.configure({ - source: { id: "configured-source", scope: "org" }, - scope: "user", - type: "configurable", - config: { header: "user-token" }, - }); + it.effect("registers and starts client-credentials OAuth through Executor tools", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + coreTools: { webBaseUrl: "http://localhost:3000" }, + redirectUri: null, + }); + yield* executor.demo.seed(); + + const client = OAuthClientSlug.make("demo-machine"); + const registered = yield* executor.execute( + ToolAddress.make("executor.coreTools.oauth.clients.create"), + { + owner: "org", + slug: String(client), + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "client_credentials", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.resourceUrl, + }, + ); + expect(registered).toEqual({ client: String(client) }); + + const started = yield* executor.execute( + ToolAddress.make("executor.coreTools.oauth.start"), + { + client: String(client), + clientOwner: "org", + owner: "org", + name: "oauth", + integration: String(INTEG), + template: String(TEMPLATE), + }, + ); + expect(started).toMatchObject({ + status: "connected", + connection: { + owner: "org", + name: "oauth", + integration: String(INTEG), + oauthClient: String(client), + oauthClientOwner: "org", + }, + }); - const orgConfig = yield* executor.configurable.getConfigAtScope("org"); - const visibleConfig = yield* executor.configurable.getVisibleConfig(); + const requests = yield* server.requests; + const tokenRequest = requests.find( + (request) => + request.path === "/token" && request.body.includes("grant_type=client_credentials"), + ); + expect(tokenRequest).toBeDefined(); + expect(new URLSearchParams(tokenRequest!.body).get("resource")).toBe(server.resourceUrl); - expect(orgConfig?.data).toEqual({ header: "org-token", sourceScope: "org" }); - expect(visibleConfig?.data).toEqual({ header: "user-token", sourceScope: "org" }); - }), + const out = yield* executor.execute(ToolAddress.make("tools.demo.org.oauth.run"), {}); + expect(out).toEqual({ ran: "run" }); + }), + ), ); - it.effect("core tools configure sources through agent-visible tool calls", () => + it.effect("orders integration detection results by confidence", () => Effect.gen(function* () { - const orgScope = Scope.make({ - id: ScopeId.make("org"), - name: "Org", - createdAt: new Date(), - }); - const userScope = Scope.make({ - id: ScopeId.make("user"), - name: "User", - createdAt: new Date(), - }); - const config = makeTestConfig({ - scopes: [userScope, orgScope], - plugins: [configurableSourcePlugin] as const, - }); - const executor = yield* createExecutor({ - ...config, - coreTools: { webBaseUrl: "http://executor.test" }, - }); - - yield* executor.configurable.registerSource("org"); - - expect((yield* executor.tools.list()).map((tool) => tool.id)).not.toContain( - "executor.coreTools.sources.configureSchemas", - ); - const presets = yield* executor.tools.invoke("executor.coreTools.sources.presets", { - query: "demo", - }); - expect(presets).toMatchObject({ - presets: [ - expect.objectContaining({ - pluginId: "configurable", - id: "configurable-demo", - name: "Configurable Demo", - url: "https://example.com/configurable.json", - featured: true, - }), - ], - }); - - yield* executor.tools.invoke("executor.coreTools.sources.configure", { - source: { id: "configured-source", scope: "org" }, - scope: "user", - type: "configurable", - config: { header: "agent-token" }, - }); - - const visibleConfig = yield* executor.configurable.getVisibleConfig(); - expect(visibleConfig?.data).toEqual({ header: "agent-token", sourceScope: "org" }); - - yield* executor.close(); - yield* Effect.promise(() => config.testDb.close()); + const plugins = [ + detector("low-detector", "low"), + detector("high-detector", "high"), + detector("medium-detector", "medium"), + ] as const; + const executor = yield* makeTestExecutor({ plugins }); + const results = yield* executor.integrations.detect("https://example.com/thing"); + // Every detector recognizes the URL; the list contains all three. + expect(results.map((r) => r.kind).sort()).toEqual([ + "high-detector", + "low-detector", + "medium-detector", + ]); }), ); - it.effect("core tools generate browser handoff URLs for secret values", () => + it.effect("tools.schema returns roots with shared reachable definitions", () => Effect.gen(function* () { - const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); - const executor = yield* createExecutor({ - ...config, - coreTools: { webBaseUrl: "http://executor.test" }, - }); - - const result = yield* executor.tools.invoke("executor.coreTools.secrets.create", { - name: "api-token", - provider: "memory", - }); - - expect(result).toMatchObject({ - id: expect.any(String), - url: expect.any(String), - instructions: expect.stringContaining("placeholder"), - }); - const url = new URL((result as { readonly url: string }).url); - expect(url.origin).toBe("http://executor.test"); - expect(url.pathname).toBe("/secrets"); - expect(url.searchParams.get("scope")).toBe("test-scope"); - expect(url.searchParams.get("name")).toBe("api-token"); - expect(url.searchParams.get("provider")).toBe("memory"); - expect(url.searchParams.get("secretId")).toBe((result as { readonly id: string }).id); - - const idResult = yield* executor.tools.invoke("executor.coreTools.secrets.create", { - scope: "test-scope", - name: "api-token-by-id", - provider: "memory", - }); - const idUrl = new URL((idResult as { readonly url: string }).url); - expect(idUrl.searchParams.get("scope")).toBe("test-scope"); - expect(idUrl.searchParams.get("name")).toBe("api-token-by-id"); - - const invalidProvider = yield* executor.tools.invoke("executor.coreTools.secrets.create", { - name: "api-token-invalid-provider", - provider: "vercel", - }); - expect(invalidProvider).toMatchObject({ - ok: false, - error: { - code: "secret_provider_not_found", - message: - 'Unknown secret storage provider "vercel". Omit provider unless the user chose one from secrets.providers.', - details: { providers: ["memory"] }, + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + }); + yield* executor.demo.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make("v"), }, }); - yield* executor.close(); - yield* Effect.promise(() => config.testDb.close()); + const schema = yield* executor.tools.schema(addr("inspect")); + expect(schema).not.toBeNull(); + const defs = schema?.schemaDefinitions ?? {}; + // Reachable defs from inspect's input/output are attached; Unused is not. + expect(Object.keys(defs).sort()).toEqual(["Cat", "Collar", "Dog", "Owner", "Pet"]); }), ); - it.effect("core tools require an explicit secret scope when multiple scopes are visible", () => + it.effect("execute dispatches a connection-produced tool to the owning plugin", () => Effect.gen(function* () { - const orgScope = Scope.make({ - id: ScopeId.make("org"), - name: "Org", - createdAt: new Date(), - }); - const userScope = Scope.make({ - id: ScopeId.make("user"), - name: "User", - createdAt: new Date(), - }); - const config = makeTestConfig({ - scopes: [userScope, orgScope], - plugins: [memorySecretsPlugin()] as const, - }); - const executor = yield* createExecutor({ - ...config, - coreTools: { webBaseUrl: "http://executor.test" }, - }); - - const result = yield* executor.tools.invoke("executor.coreTools.secrets.create", { - name: "api-token", - }); - - expect(result).toMatchObject({ - ok: false, - error: { - code: "scope_not_found", - message: - "Multiple scopes are visible. Call scopes.list and pass the target scope id or name.", + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + }); + yield* executor.demo.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make("v"), }, }); - yield* executor.close(); - yield* Effect.promise(() => config.testDb.close()); + const out = yield* executor.execute(addr("run"), {}); + expect(out).toEqual({ ran: "run" }); }), ); - it.effect("core tools cover web UI source secret and policy management flows", () => + it.effect("execute on a missing address fails with ToolNotFoundError", () => Effect.gen(function* () { - const config = makeTestConfig({ - plugins: [memorySecretsPlugin(), configurableSourcePlugin] as const, - }); - const executor = yield* createExecutor({ - ...config, - coreTools: { webBaseUrl: "http://executor.test" }, - }); - - yield* executor.configurable.registerSource("test-scope"); - yield* executor.secrets.set({ - id: SecretId.make("agent-secret"), - name: "Agent secret", - value: "secret-value", - scope: ScopeId.make("test-scope"), - provider: "memory", - }); - - expect( - yield* executor.tools.invoke("executor.coreTools.secrets.providers", {}), - ).toMatchObject({ - providers: expect.arrayContaining(["memory"]), - }); - expect( - yield* executor.tools.invoke("executor.coreTools.secrets.status", { - id: "agent-secret", - }), - ).toEqual({ id: "agent-secret", status: "resolved" }); - expect( - yield* executor.tools.invoke("executor.coreTools.secrets.usages", { - id: "agent-secret", - }), - ).toEqual({ usages: [] }); - - const createdPolicy = yield* executor.tools.invoke("executor.coreTools.policies.create", { - targetScope: "test-scope", - pattern: "configured-source.*", - action: "require_approval", - }); - const policyId = (createdPolicy as { readonly policy: { readonly id: string } }).policy.id; - expect(createdPolicy).toMatchObject({ - policy: { - id: expect.any(String), - scopeId: "test-scope", - pattern: "configured-source.*", - action: "require_approval", + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + }); + yield* executor.demo.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make("v"), }, }); - expect(yield* executor.tools.invoke("executor.coreTools.policies.list", {})).toMatchObject({ - policies: [expect.objectContaining({ id: policyId })], - }); - expect( - yield* executor.tools.invoke("executor.coreTools.policies.update", { - id: policyId, - targetScope: "test-scope", - action: "approve", - }), - ).toMatchObject({ policy: { id: policyId, action: "approve" } }); - yield* executor.tools.invoke("executor.coreTools.policies.remove", { - id: policyId, - targetScope: "test-scope", - }); - expect(yield* executor.policies.list()).toEqual([]); - - yield* executor.tools.invoke("executor.coreTools.sources.refresh", { - id: "configured-source", - targetScope: "test-scope", - }); - yield* executor.tools.invoke("executor.coreTools.sources.remove", { - id: "configured-source", - targetScope: "test-scope", - }); - expect((yield* executor.sources.list()).map((source) => source.id)).not.toContain( - "configured-source", - ); - - yield* executor.tools.invoke("executor.coreTools.secrets.remove", { - id: "agent-secret", - targetScope: "test-scope", - }); - expect(yield* executor.secrets.list()).toEqual([]); - - yield* executor.close(); - yield* Effect.promise(() => config.testDb.close()); - }), - ); - - it.effect("core tools start OAuth and expose completed connections", () => - Effect.scoped( - Effect.gen(function* () { - const oauthServer = yield* serveOAuthTestServer(); - const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); - const executor = yield* createExecutor({ - ...config, - coreTools: { webBaseUrl: "http://executor.test" }, - oauthEndpointUrlPolicy: { allowHttp: true }, - }); - - yield* executor.secrets.set({ - id: SecretId.make("client-id"), - name: "OAuth client id", - value: "test-client", - scope: ScopeId.make("test-scope"), - provider: "memory", - }); - yield* executor.secrets.set({ - id: SecretId.make("client-secret"), - name: "OAuth client secret", - value: "test-secret", - scope: ScopeId.make("test-scope"), - provider: "memory", - }); - - const schema = yield* executor.tools.schema("executor.coreTools.oauth.start"); - expect(schema?.inputTypeScript).toContain("credentialScope?: string"); - expect(schema?.inputTypeScript).not.toContain("scope: string; endpoint"); - - const started = yield* executor.tools.invoke("executor.coreTools.oauth.start", { - credentialScope: "test-scope", - endpoint: oauthServer.resourceUrl, - connectionId: "agent-oauth", - pluginId: "test-plugin", - strategy: { - kind: "client-credentials", - tokenEndpoint: oauthServer.tokenEndpoint, - clientIdSecretId: "client-id", - clientSecretSecretId: "client-secret", - scopes: ["read"], - }, - }); - - expect(started).toMatchObject({ - authorizationUrl: null, - completedConnection: { connectionId: "agent-oauth" }, - instructions: expect.stringContaining("completed without a browser handoff"), - }); - - const listed = yield* executor.tools.invoke("executor.coreTools.connections.list", {}); - expect(listed).toMatchObject({ - connections: [expect.objectContaining({ id: "agent-oauth", provider: "oauth2" })], - }); - expect( - yield* executor.tools.invoke("executor.coreTools.connections.providers", {}), - ).toMatchObject({ - providers: expect.arrayContaining(["oauth2"]), - }); - expect( - yield* executor.tools.invoke("executor.coreTools.connections.usages", { - id: "agent-oauth", - }), - ).toEqual({ usages: [] }); - yield* executor.tools.invoke("executor.coreTools.connections.remove", { - id: "agent-oauth", - targetScope: "test-scope", - }); - expect(yield* executor.connections.list()).toEqual([]); - - yield* executor.close(); - yield* Effect.promise(() => config.testDb.close()); - }), - ), - ); - - it.effect("core OAuth tools return actionable tool failures for expected errors", () => - Effect.gen(function* () { - const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); - const executor = yield* createExecutor({ - ...config, - coreTools: { webBaseUrl: "http://executor.test" }, - oauthEndpointUrlPolicy: { allowHttp: true }, - }); - - const result = yield* executor.tools.invoke("executor.coreTools.oauth.probe", { - endpoint: "http://127.0.0.1:1/mcp", - }); - - expect(result).toMatchObject({ - ok: false, - error: { - code: "oauth_probe_failed", + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("other"), + integration: INTEG, + template: TEMPLATE, + from: { + provider: ProviderKey.make("memory"), + id: ProviderItemId.make("v"), }, }); - yield* executor.close(); - yield* Effect.promise(() => config.testDb.close()); + const result = yield* Effect.result(executor.execute(addr("un"), {})); + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + const error = result.failure; + expect(Predicate.isTagged(error, "ToolNotFoundError")).toBe(true); + const suggestions = (error as ToolNotFoundError).suggestions ?? []; + expect(suggestions).toEqual([addr("run")]); + expect( + suggestions.every((suggestion) => + String(suggestion).startsWith(`tools.${INTEG}.org.${CONN}.`), + ), + ).toBe(true); }), ); - - it.effect("core tools start browser OAuth and expose the completed connection", () => - Effect.scoped( - Effect.gen(function* () { - const oauthServer = yield* serveOAuthTestServer(); - const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); - const executor = yield* createExecutor({ - ...config, - coreTools: { webBaseUrl: "http://executor.test" }, - oauthEndpointUrlPolicy: { allowHttp: true }, - }); - - yield* executor.secrets.set({ - id: SecretId.make("browser-client-id"), - name: "OAuth client id", - value: "test-client", - scope: ScopeId.make("test-scope"), - provider: "memory", - }); - yield* executor.secrets.set({ - id: SecretId.make("browser-client-secret"), - name: "OAuth client secret", - value: "test-secret", - scope: ScopeId.make("test-scope"), - provider: "memory", - }); - - const started = yield* executor.tools.invoke("executor.coreTools.oauth.start", { - credentialScope: "test", - endpoint: oauthServer.resourceUrl, - connectionId: "agent-browser-oauth", - pluginId: "test-plugin", - strategy: { - kind: "authorization-code", - authorizationEndpoint: oauthServer.authorizationEndpoint, - tokenEndpoint: oauthServer.tokenEndpoint, - clientIdSecretId: "browser-client-id", - clientSecretSecretId: "browser-client-secret", - scopes: ["read"], - }, - }); - expect(started).toMatchObject({ - authorizationUrl: expect.stringContaining(oauthServer.authorizationEndpoint), - completedConnection: null, - instructions: expect.stringContaining("open this authorization URL"), - }); - - const authorizationUrl = (started as { authorizationUrl: string }).authorizationUrl; - const callback = yield* oauthServer.completeAuthorizationCodeFlow({ authorizationUrl }); - const completed = yield* executor.oauth.complete({ - state: callback.state, - code: callback.code, - }); - expect(completed.connectionId).toBe("agent-browser-oauth"); - - const listed = yield* executor.tools.invoke("executor.coreTools.connections.list", {}); - expect(listed).toMatchObject({ - connections: [expect.objectContaining({ id: "agent-browser-oauth", provider: "oauth2" })], - }); - - yield* executor.close(); - yield* Effect.promise(() => config.testDb.close()); - }), - ), - ); }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 05caea8e4..e6b22cf7f 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,23 +1,12 @@ -import { - Deferred, - Duration, - Effect, - Layer, - Match, - Option, - Predicate, - Result, - Schema, - Semaphore, -} from "effect"; +import { Effect, Layer, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { fumadb } from "fumadb"; import { memoryAdapter } from "fumadb/adapters/memory"; import { withQueryContext, type Condition, type ConditionBuilder } from "fumadb/query"; import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; import type { AnyColumn } from "fumadb/schema"; -import type { OAuthEndpointUrlPolicy } from "./oauth-helpers"; import { generateKeyBetween } from "fractional-indexing"; + import { StorageError, isStorageFailure, @@ -27,51 +16,24 @@ import { type FumaTables, type StorageFailure, } from "./fuma-runtime"; - -import { makeFumaBlobStore, pluginBlobStore } from "./blob"; +import { makeFumaBlobStore, pluginBlobStore, type OwnerPartitions } from "./blob"; import { coreToolsPlugin } from "./core-tools"; -import { - ConnectionProviderState, - ConnectionIdentityOverride, +import type { + Connection, + ConnectionInputOrigin, ConnectionRef, - ConnectionRefreshError, - type ConnectionProvider, - type ConnectionRefreshResult, - type CreateConnectionInput, - type UpdateConnectionIdentityInput, - type RemoveConnectionInput, - type UpdateConnectionTokensInput, -} from "./connections"; -import { - credentialBindingId, - credentialBindingRowToRef, - type CredentialBindingRef, - type CredentialBindingsFacade, - type CredentialBindingSlotInput, - type CredentialBindingSourceInput, - type RemoveCredentialBindingInput, - type RemoveSourceCredentialBindingInput, - type ReplaceCredentialBindingsInput, - type ReplaceSourceCredentialBindingsInput, - ResolvedCredentialSlot, - type SetPluginCredentialBindingInput, - type SetSourceCredentialBindingInput, - type SourceCredentialBindingSlotInput, - type SourceCredentialBindingSourceInput, -} from "./credential-bindings"; + CreateConnectionInput, + ConnectionValueInput, +} from "./connection"; import { coreSchema, isToolPolicyAction, type ConnectionRow, - type CredentialBindingRow, type CoreSchema, - type DefinitionsInput, - type SecretRow, - type SourceInput, - type SourceRow, - type ToolAnnotations, - type ToolPolicyRow, + type IntegrationRow, + type OAuthClientRow, type ToolRow, + type ToolPolicyRow, } from "./core-schema"; import { ElicitationDeclinedError, @@ -79,46 +41,75 @@ import { FormElicitation, type ElicitationHandler, type ElicitationRequest, + type OnElicitation, + type InvokeOptions, } from "./elicitation"; + +export type { OnElicitation, InvokeOptions } from "./elicitation"; import { - ConnectionInUseError, ConnectionNotFoundError, - ConnectionProviderNotRegisteredError, - ConnectionReauthRequiredError, - ConnectionRefreshNotSupportedError, + CredentialProviderNotRegisteredError, + CredentialResolutionError, + IntegrationNotFoundError, + InvalidConnectionInputError, + IntegrationRemovalNotAllowedError, NoHandlerError, PluginNotLoadedError, - SecretInUseError, - SecretOwnedByConnectionError, - SourceRemovalNotAllowedError, ToolBlockedError, ToolInvocationError, ToolNotFoundError, + type ExecuteError, } from "./errors"; -import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids"; -import { makeOAuth2Service } from "./oauth-service"; -import type { OAuthService } from "./oauth"; +import { + AuthTemplateSlug, + ConnectionAddress, + ConnectionName, + IntegrationSlug, + NO_AUTH_TEMPLATE, + OAuthClientSlug, + Owner, + PolicyId, + ProviderItemId, + ProviderKey, + Subject, + Tenant, + ToolAddress, + ToolName, +} from "./ids"; +import type { + AuthMethodDescriptor, + Integration, + IntegrationConfig, + RegisterIntegrationInput, +} from "./integration"; +import { makeOAuthService, type MintOAuthConnectionInput } from "./oauth-service"; +import type { OAuthService } from "./oauth-client"; import { comparePolicyRow, isValidPattern, - resolveToolPolicy, + resolveEffectivePolicy, rowToToolPolicy, type CreateToolPolicyInput, - type PolicyMatch, + type EffectivePolicy, type RemoveToolPolicyInput, type ToolPolicy, type UpdateToolPolicyInput, } from "./policies"; +import type { CredentialProvider, ProviderEntry } from "./provider"; import type { AnyPlugin, Elicit, + IntegrationConfigureSchema, + IntegrationPresetCatalogEntry, + IntegrationRecord, + OwnerBinding, PluginCtx, PluginExtensions, - SourceConfigureSchema, + ResolveToolsResult, StaticSourceDecl, StaticToolDecl, - StaticToolSchema, StorageDeps, + ToolInvocationCredential, } from "./plugin"; import { pluginStorageId, @@ -130,52 +121,33 @@ import { type PluginStorageRuntimeCollectionDefinition, type PluginStorageRuntimeIndexSpec, } from "./plugin-storage"; -import type { Scope } from "./scope"; -import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; -import { Usage } from "./usages"; import { - ToolSchemaView, - type RefreshSourceInput, - type RemoveSourceInput, - type Source, - type SourceDetectionResult, - type ToolView, - type ToolListFilter, -} from "./types"; -import { buildToolTypeScriptPreview, type ToolTypeScriptPreview } from "./schema-types"; + assertExecutorOwnerPolicyTable, + ORG_SUBJECT, + type ExecutorOwnerPolicyContext, +} from "./owner-policy"; +import { ToolSchemaView, type IntegrationDetectionResult } from "./types"; +import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool"; +import { buildToolTypeScriptPreview } from "./schema-types"; import { collectReferencedDefinitions } from "./schema-refs"; -import { assertExecutorScopePolicyTable, type ExecutorScopePolicyContext } from "./scope-policy"; -import { validateHostedOutboundUrl } from "./hosted-http-client"; - -const MAX_ANNOTATION_GROUPS = 64; +import { + refreshAccessToken, + exchangeClientCredentials, + shouldRefreshToken, + type OAuthEndpointUrlPolicy, +} from "./oauth-helpers"; +import { connectionIdentifier } from "./connection-name-identifier"; + +const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; // --------------------------------------------------------------------------- -// Elicitation handler — set once at `createExecutor({ onElicitation })` -// and threaded into every tool invocation. A tool that requests user -// input mid-execution suspends the fiber and the handler decides how to -// respond. Tools that never elicit simply don't trigger the handler. -// -// The "accept-all" sentinel is convenient for tests and CLI automation — -// every elicitation request gets auto-accepted with an empty content -// payload. For real interactive hosts, pass a real handler. -// -// Required at the executor level rather than per-invoke, so the -// "what if a caller forgot to pass a handler" branch is structurally -// impossible. Higher layers that need per-invocation handler control -// (an MCP server bridging different per-client handlers, the execution -// engine threading agent-loop callbacks) can pass an override via -// `tools.invoke(id, args, { onElicitation })` — the executor-level -// handler is the fallback, never null. +// Elicitation handler — resolved once at `createExecutor({ onElicitation })` +// and overridable per `execute`. A tool that requests user input mid-execution +// suspends the fiber and the handler decides how to respond. The "accept-all" +// sentinel auto-accepts (tests / non-interactive hosts). // --------------------------------------------------------------------------- -export type OnElicitation = ElicitationHandler | "accept-all"; - -export interface InvokeOptions { - /** Override the executor-level handler for this single call. */ - readonly onElicitation?: OnElicitation; -} - const acceptAllHandler: ElicitationHandler = () => Effect.succeed(ElicitationResponse.make({ action: "accept" })); @@ -183,204 +155,158 @@ const resolveElicitationHandler = (onElicitation: OnElicitation): ElicitationHan onElicitation === "accept-all" ? acceptAllHandler : onElicitation; // --------------------------------------------------------------------------- -// Executor — public surface. Every list/invoke/schema call is a direct -// core-table query (for dynamic rows) unioned with the in-memory static -// pool. No ToolRegistry, no SourceRegistry, no SecretStore services. +// Address scheme — `tools....`. // --------------------------------------------------------------------------- -export type Executor = { - /** - * Precedence-ordered scope stack this executor was configured with. - * Innermost first. Consumers that need "the display scope" typically - * pick `scopes.at(-1)` (outermost, e.g. the organization) or - * `scopes[0]` (innermost, e.g. the current user-in-org) depending on - * what they're rendering. - */ - readonly scopes: readonly Scope[]; +const ADDRESS_PREFIX = "tools"; - readonly tools: { - readonly list: (filter?: ToolListFilter) => Effect.Effect; - /** Fetch a tool's schema view: JSON schemas with `$defs` - * attached from the core `definition` table, plus TypeScript - * preview strings. Returns `null` for unknown tool ids. */ - readonly schema: (toolId: string) => Effect.Effect; - /** Every `$defs` entry across every source, grouped by source id. - * Used for bulk schema export and downstream TypeScript rendering. */ - readonly definitions: () => Effect.Effect< - Record>, - StorageFailure - >; - readonly invoke: ( - toolId: string, - args: unknown, - options?: InvokeOptions, - ) => Effect.Effect< - unknown, - | ToolNotFoundError - | ToolBlockedError - | PluginNotLoadedError - | NoHandlerError - | ToolInvocationError - | ElicitationDeclinedError - | StorageFailure - >; +export interface ParsedToolAddress { + readonly integration: IntegrationSlug; + readonly owner: Owner; + readonly connection: ConnectionName; + readonly tool: ToolName; +} + +const isOwner = (value: string): value is Owner => value === "org" || value === "user"; + +/** Parse a callable address; null when it's not a well-formed + * `tools....`. + * + * The four leading segments (prefix, integration, owner, connection) are + * slug-like and never contain a `.`; the `` segment is the *entire* + * remainder after the 4th dot, so it may itself contain dots. That lets a tool + * whose name is a structured `group.leaf` path (e.g. an OpenAPI + * `aliases.deleteAlias`) address naturally as + * `tools....aliases.deleteAlias` — the same + * dotted nesting the sandbox `tools` proxy produces from property access. */ +export const parseToolAddress = (address: string): ParsedToolAddress | null => { + // Walk to the 4th dot; everything past it is the tool (dots and all). + let cut = -1; + for (let i = 0; i < 4; i++) { + cut = address.indexOf(".", cut + 1); + if (cut === -1) return null; + } + const [prefix, integration, owner, connection] = address.slice(0, cut).split(".") as [ + string, + string, + string, + string, + ]; + const tool = address.slice(cut + 1); + if (prefix !== ADDRESS_PREFIX) return null; + if (!isOwner(owner)) return null; + if (integration.length === 0 || connection.length === 0 || tool.length === 0) { + return null; + } + return { + integration: IntegrationSlug.make(integration), + owner, + connection: ConnectionName.make(connection), + tool: ToolName.make(tool), }; +}; - readonly sources: { - readonly list: () => Effect.Effect; +export const connectionAddress = ( + owner: Owner, + integration: IntegrationSlug, + connection: ConnectionName, +): ConnectionAddress => + ConnectionAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}`); + +export const toolAddress = ( + owner: Owner, + integration: IntegrationSlug, + connection: ConnectionName, + tool: ToolName, +): ToolAddress => + ToolAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}.${tool}`); + +// --------------------------------------------------------------------------- +// Owner key helpers — every owned-row write stamps `tenant`, `owner`, +// `subject` (org → subject=""). +// --------------------------------------------------------------------------- + +interface OwnedKeys { + readonly tenant: string; + readonly owner: Owner; + readonly subject: string; +} + +// --------------------------------------------------------------------------- +// Executor — public surface. Every list/execute/schema call is a direct +// core-table query unioned with the in-memory static pool. +// --------------------------------------------------------------------------- + +export type Executor = { + readonly integrations: { + readonly list: () => Effect.Effect; + readonly get: (slug: IntegrationSlug) => Effect.Effect; + readonly update: ( + slug: IntegrationSlug, + patch: { readonly description?: string }, + ) => Effect.Effect; readonly remove: ( - input: RemoveSourceInput, - ) => Effect.Effect; - readonly refresh: (input: RefreshSourceInput) => Effect.Effect; - /** URL autodetection — fans out to every plugin's `detect` hook - * (if declared), returns every high/medium/low-confidence match. - * UI picks a winner from the list. */ + slug: IntegrationSlug, + ) => Effect.Effect; readonly detect: ( url: string, - ) => Effect.Effect; - /** All `$defs` registered for a single source, keyed by def name. */ - readonly definitions: ( - sourceId: string, - ) => Effect.Effect, StorageFailure>; - readonly configure: (input: { - readonly source: { - readonly id: string; - readonly scope: ScopeId | string; - }; - readonly scope: ScopeId | string; - readonly type?: string; - readonly config: unknown; - }) => Effect.Effect; - readonly listBindings: ( - input: SourceCredentialBindingSourceInput, - ) => Effect.Effect; - readonly resolveBinding: ( - input: SourceCredentialBindingSlotInput, - ) => Effect.Effect; - readonly setBinding: ( - input: SetSourceCredentialBindingInput, - ) => Effect.Effect; - readonly removeBinding: ( - input: RemoveSourceCredentialBindingInput, - ) => Effect.Effect; - readonly replaceBindings: ( - input: ReplaceSourceCredentialBindingsInput, - ) => Effect.Effect; - }; - - readonly secrets: { - readonly get: ( - id: string, - ) => Effect.Effect; - readonly getAtScope: ( - id: string, - scope: string, - ) => Effect.Effect; - /** Fast-path existence check — hits the core `secret` routing table - * only, never calls the provider. Use this for UI state ("secret - * missing, prompt to add") to avoid keychain permission prompts - * or 1password IPC roundtrips on a pre-flight check. */ - readonly status: (id: string) => Effect.Effect<"resolved" | "missing", StorageFailure>; - readonly set: (input: SetSecretInput) => Effect.Effect; - /** Delete a bare (non-connection-owned) secret. Connection-owned - * secrets are rejected with `SecretOwnedByConnectionError` — use - * `connections.remove` instead. Refuses with `SecretInUseError` - * if any plugin reports the secret as in use; the caller should - * show the `usages(id)` list and ask the user to detach first. */ - readonly remove: ( - input: RemoveSecretInput, - ) => Effect.Effect; - readonly list: () => Effect.Effect; - /** Management view of visible secret rows. Unlike `list`, this does - * not collapse same-id rows across scopes, so UI that writes exact - * credential targets can show both personal and shared rows. */ - readonly listAll: () => Effect.Effect; - /** All places this secret is referenced — fans out across every - * plugin's `usagesForSecret`. Used by the Secrets-tab "Used by" - * list and by `remove` for its RESTRICT check. */ - readonly usages: (id: string) => Effect.Effect; - readonly providers: () => Effect.Effect; + ) => Effect.Effect; }; readonly connections: { - readonly get: (id: string) => Effect.Effect; - readonly getAtScope: ( - id: string, - scope: string, - ) => Effect.Effect; - readonly list: () => Effect.Effect; readonly create: ( input: CreateConnectionInput, - ) => Effect.Effect; - readonly updateTokens: ( - input: UpdateConnectionTokensInput, - ) => Effect.Effect; - readonly setIdentityLabel: ( - id: string, - label: string | null, - ) => Effect.Effect; - readonly setIdentityOverride: ( - input: UpdateConnectionIdentityInput, - ) => Effect.Effect; - readonly accessToken: ( - id: string, ) => Effect.Effect< - string, - | ConnectionNotFoundError - | ConnectionProviderNotRegisteredError - | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError - | ConnectionRefreshError + Connection, + | IntegrationNotFoundError + | CredentialProviderNotRegisteredError + | InvalidConnectionInputError | StorageFailure >; - readonly accessTokenAtScope: ( - id: string, - scope: string, + readonly list: (filter?: { + readonly integration?: IntegrationSlug; + readonly owner?: Owner; + }) => Effect.Effect; + readonly get: (ref: ConnectionRef) => Effect.Effect; + readonly remove: ( + ref: ConnectionRef, + ) => Effect.Effect; + readonly refresh: ( + ref: ConnectionRef, ) => Effect.Effect< - string, - | ConnectionNotFoundError - | ConnectionProviderNotRegisteredError - | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError - | ConnectionRefreshError - | StorageFailure + readonly Tool[], + ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure >; - /** Refuses with `ConnectionInUseError` if any plugin reports the - * connection as in use. */ - readonly remove: ( - input: RemoveConnectionInput, - ) => Effect.Effect; - /** All places this connection is referenced — fans out across every - * plugin's `usagesForConnection`. */ - readonly usages: (id: string) => Effect.Effect; - readonly providers: () => Effect.Effect; }; - /** Shared credential slot bindings. Plugins decide what slot keys mean; - * core owns scoped storage, resolution status, and usage visibility. */ - readonly credentialBindings: CredentialBindingsFacade; - /** Shared OAuth service. Hosts use this through the core HTTP OAuth group; * plugins see the same service as `ctx.oauth`. */ readonly oauth: OAuthService; + readonly tools: { + readonly list: (filter?: ToolListFilter) => Effect.Effect; + readonly schema: (address: ToolAddress) => Effect.Effect; + }; + + readonly providers: { + readonly list: () => Effect.Effect; + readonly items: (key: ProviderKey) => Effect.Effect; + }; + readonly policies: { - /** All policies visible across the executor's scope stack, sorted - * by (innermost-scope-first, position ascending) — i.e. the order - * in which they're evaluated by first-match-wins. */ readonly list: () => Effect.Effect; - /** Create a new policy. Defaults to the top of the target scope's - * list (highest precedence) when `position` is omitted. */ readonly create: (input: CreateToolPolicyInput) => Effect.Effect; readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; - /** Resolve the effective policy for a tool id by walking the scope- - * stacked policy list with first-match-wins semantics. Returns - * `undefined` when no rule matches (caller falls back to the - * plugin's `resolveAnnotations` output). */ - readonly resolve: (toolId: string) => Effect.Effect; + readonly resolve: (address: ToolAddress) => Effect.Effect; }; + readonly execute: ( + address: ToolAddress, + args: unknown, + options?: InvokeOptions, + ) => Effect.Effect; + readonly close: () => Effect.Effect; } & PluginExtensions; @@ -396,51 +322,39 @@ export type ExecutorDbFactory = (config: { }) => ExecutorDbInput | Effect.Effect; export interface ExecutorConfig { - /** - * Precedence-ordered scope stack. Innermost first; typical shape is - * `[userInOrgScope, orgScope]`. Reads on scoped tables walk the - * stack (first hit wins for shadow-by-id consumers like secrets and - * blobs); writes require callers to name an explicit target scope. - * Must be non-empty. - */ - readonly scopes: readonly Scope[]; + /** The org / workspace this executor is bound to. `owner: "org"` rows file + * here. */ + readonly tenant: Tenant; + /** The acting member, or omit for a pure-org executor (no `owner:"user"`). */ + readonly subject?: Subject; readonly db?: ExecutorDbInput | ExecutorDbFactory; readonly plugins?: TPlugins; + /** Config-level credential providers, merged with every + * `plugin.credentialProviders`. Config providers register first, so the + * default (first writable) store is selected from them when present. */ + readonly providers?: readonly CredentialProvider[]; /** * How to respond when a tool requests user input mid-invocation. Pass - * `"accept-all"` for tests / non-interactive hosts, or a handler - * `(ctx) => Effect` for interactive ones. - * Required at construction so per-invoke calls don't have to thread - * an options arg. + * `"accept-all"` for tests / non-interactive hosts, or a handler. */ readonly onElicitation: OnElicitation; readonly httpClientLayer?: Layer.Layer; + /** + * The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and + * sends to providers. There is NO localhost default: omit it (or pass + * undefined) only for executors that never run interactive OAuth — the + * redirect-requiring flows then fail loudly instead of guessing a callback. + * Hosts serving OAuth derive this from the request origin / web base URL. + */ + readonly redirectUri?: string; readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy; - readonly sourceDetection?: { - readonly maxUrlLength?: number; - readonly maxDetectors?: number; - readonly maxResults?: number; - readonly timeout?: Duration.Input; - readonly hostedOutboundPolicy?: boolean; - }; /** - * Enable the built-in `core-tools` plugin which contributes - * agent-facing static tools (`scopes.list`, `secrets.list`, - * `secrets.create`). The `webBaseUrl` is where the executor's web - * UI lives; `secrets.create` builds a URL elicitation that points - * the user at `${webBaseUrl}/secrets?...` so the plaintext value - * never crosses the agent. - * - * Omit to skip registration (tests, MCP-only hosts that don't - * surface a web UI, etc.). - * - * `webBaseUrl` is optional: a host that can't know its public URL at boot (a - * Worker has no static URL var) leaves it unset and derives it per request. - * The browser-handoff tools (`secrets.create`, OAuth callback) fail clearly if - * it is still absent when actually invoked. + * Enable the built-in `core-tools` plugin which contributes agent-facing + * static tools over the v2 surface (integrations / connections / policies). */ readonly coreTools?: { readonly webBaseUrl?: string; + readonly includeProviders?: boolean; }; } @@ -451,13 +365,13 @@ export interface ExecutorConfig { - validateExecutorScopePolicyTables(coreSchema); + validateExecutorOwnerPolicyTables(coreSchema); return { ...coreSchema }; }; -const validateExecutorScopePolicyTables = (tables: FumaTables): void => { +const validateExecutorOwnerPolicyTables = (tables: FumaTables): void => { for (const [tableKey, tableDef] of Object.entries(tables)) { - assertExecutorScopePolicyTable(tableDef, tableKey); + assertExecutorOwnerPolicyTable(tableDef, tableKey); } }; @@ -496,43 +410,13 @@ const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => { // oxlint-disable-next-line executor/no-double-cast -- boundary: fumadb's generic ORM client type doesn't structurally match the FumaDb facade const db = factory.client(memoryAdapter()).orm(version) as unknown as FumaDb; - return { - db, - }; + return { db }; }; // --------------------------------------------------------------------------- -// Row → public projection conversions +// JSON helpers + row → public projection conversions // --------------------------------------------------------------------------- -const rowToSource = (row: SourceRow, connectionIds: readonly string[] = []): Source => ({ - id: row.id, - scopeId: row.scope_id, - kind: row.kind, - name: row.name, - url: row.url ?? undefined, - pluginId: row.plugin_id, - canRemove: Boolean(row.can_remove), - canRefresh: Boolean(row.can_refresh), - canEdit: Boolean(row.can_edit), - connectionIds, - runtime: false, -}); - -const staticDeclToSource = (decl: StaticSourceDecl, pluginId: string): Source => ({ - id: decl.id, - scopeId: undefined, - kind: decl.kind, - name: decl.name, - url: decl.url, - pluginId, - canRemove: decl.canRemove ?? false, - canRefresh: decl.canRefresh ?? false, - canEdit: decl.canEdit ?? false, - connectionIds: [], - runtime: true, -}); - const decodeJsonFromString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); const decodeJsonColumn = (value: unknown): unknown => { @@ -541,193 +425,123 @@ const decodeJsonColumn = (value: unknown): unknown => { return decodeJsonFromString(value).pipe(Option.getOrElse(() => value)); }; -const decodeProviderState = Schema.decodeUnknownOption(ConnectionProviderState); -const decodeConnectionIdentityOverride = Schema.decodeUnknownOption(ConnectionIdentityOverride); - -const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): ToolView => ({ - id: row.id, - sourceId: row.source_id, - pluginId: row.plugin_id, - name: row.name, +const rowToIntegration = ( + row: IntegrationRow, + authMethods: readonly AuthMethodDescriptor[] = [], + displayUrl?: string, +): Integration => ({ + slug: IntegrationSlug.make(row.slug), description: row.description, - inputSchema: decodeJsonColumn(row.input_schema), - outputSchema: decodeJsonColumn(row.output_schema), - annotations, + kind: row.plugin_id, + canRemove: Boolean(row.can_remove), + canRefresh: Boolean(row.can_refresh), + authMethods, + ...(displayUrl ? { displayUrl } : {}), }); -const staticDeclToTool = ( - source: StaticSourceDecl, - tool: StaticToolDecl, - pluginId: string, -): ToolView => ({ - id: `${source.id}.${tool.name}`, - sourceId: source.id, - pluginId, - name: tool.name, - description: tool.description, - inputSchema: toToolJsonSchema(tool.inputSchema), - outputSchema: toToolJsonSchema(tool.outputSchema, "output"), - annotations: tool.annotations, +const rowToIntegrationRecord = ( + row: IntegrationRow, + authMethods: readonly AuthMethodDescriptor[] = [], +): IntegrationRecord => ({ + ...rowToIntegration(row, authMethods), + config: decodeJsonColumn(row.config), }); -const toToolJsonSchema = ( - schema: StaticToolSchema | undefined, - direction: "input" | "output" = "input", -): unknown => { - if (schema == null) return undefined; - return schema["~standard"].jsonSchema[direction]({ - target: "draft-2020-12", - }); -}; - -const toConfigureJsonSchema = ( - schema: StaticToolSchema | Schema.Decoder | undefined, -): unknown => { - if (schema == null) return undefined; - const standard = schema as { - readonly "~standard"?: { - readonly validate?: unknown; - readonly jsonSchema?: StaticToolSchema["~standard"]["jsonSchema"]; - }; - }; - if (typeof standard["~standard"]?.validate !== "function") { - const jsonSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(schema as Schema.Decoder) as never, - ) as StaticToolSchema; - return toToolJsonSchema(jsonSchema); - } - return standard["~standard"].jsonSchema?.input({ - target: "draft-2020-12", - }); -}; - -const decodeConfigureInput = ( - schema: StaticToolSchema | Schema.Decoder | undefined, - input: unknown, -): Effect.Effect => { - if (schema == null) return Effect.succeed(input); - const standard = schema as { - readonly "~standard"?: { readonly validate?: unknown }; +const rowToConnection = (row: ConnectionRow): Connection => { + const owner = row.owner as Owner; + const integration = IntegrationSlug.make(row.integration); + const name = ConnectionName.make(row.name); + return { + owner, + name, + integration, + template: AuthTemplateSlug.make(row.template), + provider: ProviderKey.make(row.provider), + address: connectionAddress(owner, integration, name), + identityLabel: row.identity_label ?? null, + expiresAt: row.expires_at == null ? null : Number(row.expires_at), + oauthClient: row.oauth_client == null ? null : OAuthClientSlug.make(String(row.oauth_client)), + oauthClientOwner: + row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner), + oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope), }; - if (standard["~standard"] === undefined || typeof standard["~standard"].validate !== "function") { - return Schema.decodeUnknownEffect(schema as Schema.Decoder)(input); - } - return Effect.promise(() => - Promise.resolve((standard["~standard"]!.validate as (input: unknown) => unknown)(input)), - ).pipe( - Effect.flatMap((result) => { - const validationResult = result as { readonly value?: unknown }; - return "value" in validationResult - ? Effect.succeed(validationResult.value) - : Effect.fail(result); - }), - ); }; -const sourceConfigureSchemaView = ( - pluginId: string, - configure: NonNullable, -): SourceConfigureSchema => ({ - pluginId, - type: configure.type, - schema: toConfigureJsonSchema(configure.schema), -}); - -const EXECUTOR_SOURCE_ID = "executor"; -const EXECUTOR_SOURCE: StaticSourceDecl = { - id: EXECUTOR_SOURCE_ID, - kind: "built-in", - name: "Executor", - canRemove: false, - canRefresh: false, - canEdit: false, - tools: [], -}; +/** The canonical credential variable for a single-secret connection. OAuth tokens + * and the primary apiKey value resolve through it. */ +const PRIMARY_INPUT_VARIABLE = "token"; -const scopeFilter = - (scopes: readonly string[]) => - (b: ConditionBuilder>): Condition => - scopes.length === 1 ? b("scope_id", "=", scopes[0]!) : b("scope_id", "in", [...scopes]); +interface NormalizedConnectionInput { + readonly variable: string; + readonly origin: ConnectionInputOrigin; +} -const scopedWhere = - ( - scopes: readonly string[], - where?: (b: ConditionBuilder>) => Condition | boolean, - ) => - (b: ConditionBuilder>): Condition | boolean => - b.and(scopeFilter(scopes)(b), where ? where(b) : true); - -const byId = - (id: string) => - (b: ConditionBuilder>): Condition => - b("id", "=", id); - -const byScopedId = - (scope: string, id: string) => - (b: ConditionBuilder>): Condition => - b.and(b("scope_id", "=", scope), b("id", "=", id)) as Condition; - -const toolSourceId = (toolId: string): string | null => { - const dot = toolId.indexOf("."); - return dot === -1 ? null : toolId.slice(0, dot); +/** Flatten any `ConnectionValueInput` form (single `value`/`from` sugar, pasted + * `values` map, or the canonical per-variable `inputs` map) into a uniform list + * of named origins. */ +const normalizeConnectionInputs = ( + input: ConnectionValueInput, +): readonly NormalizedConnectionInput[] => { + if ("inputs" in input) { + return Object.entries(input.inputs).map(([variable, origin]) => ({ variable, origin })); + } + if ("values" in input) { + return Object.entries(input.values).map(([variable, value]) => ({ + variable, + origin: { value }, + })); + } + if ("from" in input) { + return [{ variable: PRIMARY_INPUT_VARIABLE, origin: { from: input.from } }]; + } + return [{ variable: PRIMARY_INPUT_VARIABLE, origin: { value: input.value } }]; }; -const levenshteinDistance = (left: string, right: string): number => { - const previous = Array.from({ length: right.length + 1 }, (_, index) => index); - const current = Array.from({ length: right.length + 1 }, () => 0); - for (let i = 0; i < left.length; i++) { - current[0] = i + 1; - for (let j = 0; j < right.length; j++) { - current[j + 1] = - left[i] === right[j] - ? previous[j]! - : Math.min(previous[j]!, previous[j + 1]!, current[j]!) + 1; - } - for (let j = 0; j < previous.length; j++) previous[j] = current[j]!; - } - return previous[right.length]!; +/** Decode a connection row's `item_ids` JSON map (`variable → provider item id`). + * Tolerates the historically-single shape by returning `{}` for anything that + * isn't an object. */ +const connectionItemIds = (row: ConnectionRow): Record => { + const decoded = decodeJsonColumn(row.item_ids); + if (decoded == null || typeof decoded !== "object") return {}; + return decoded as Record; }; -const missingToolSuggestionScore = (query: string, candidate: string): number => { - const normalizedQuery = query.toLowerCase(); - const normalizedCandidate = candidate.toLowerCase(); - if (normalizedCandidate === normalizedQuery) return 0; - if (normalizedCandidate.startsWith(normalizedQuery)) return 1; - if (normalizedQuery.startsWith(normalizedCandidate)) return 2; - if (normalizedCandidate.includes(normalizedQuery)) return 3; - const queryLeaf = normalizedQuery.split(".").at(-1) ?? normalizedQuery; - const candidateLeaf = normalizedCandidate.split(".").at(-1) ?? normalizedCandidate; - if (candidateLeaf.startsWith(queryLeaf) || queryLeaf.startsWith(candidateLeaf)) return 4; - return 10 + levenshteinDistance(normalizedQuery, normalizedCandidate); +const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => { + const owner = row.owner as Owner; + const integration = IntegrationSlug.make(row.integration); + const connection = ConnectionName.make(row.connection); + const name = ToolName.make(row.name); + return { + address: toolAddress(owner, integration, connection, name), + owner, + integration, + connection, + name, + pluginId: row.plugin_id, + description: row.description, + inputSchema: decodeJsonColumn(row.input_schema), + outputSchema: decodeJsonColumn(row.output_schema), + annotations: annotations ?? (decodeJsonColumn(row.annotations) as ToolAnnotations | undefined), + }; }; -const missingToolSuggestions = ( - toolId: string, - rows: readonly { readonly id: string }[], -): readonly ToolId[] => - rows - .map((row) => ({ id: row.id, score: missingToolSuggestionScore(toolId, row.id) })) - .sort((left, right) => left.score - right.score || left.id.localeCompare(right.id)) - .slice(0, 5) - .map((item) => ToolId.make(item.id)); +// --------------------------------------------------------------------------- +// Condition builders +// --------------------------------------------------------------------------- +type AnyCb = ConditionBuilder>; type CoreTableName = keyof CoreSchema & string; type CoreRow = FumaRow; -type CoreWhere<_TName extends CoreTableName> = ( - b: ConditionBuilder>, -) => Condition | boolean; -type CoreFindManyOptions = { - readonly where?: CoreWhere; +type CoreWhere = (b: AnyCb) => Condition | boolean; +type CoreFindManyOptions = { + readonly where?: CoreWhere; readonly limit?: number; readonly offset?: number; readonly orderBy?: | readonly [string, "asc" | "desc"] | readonly (readonly [string, "asc" | "desc"])[]; }; -type CoreFindFirstOptions = Omit< - CoreFindManyOptions, - "limit" | "offset" ->; +type CoreFindFirstOptions = Omit; type LooseStorageDb = { readonly count: (tableName: string, options?: unknown) => Promise; @@ -756,7 +570,7 @@ const asLooseStorageDb = (db: unknown): LooseStorageDb => db as LooseStorageDb; const makeCoreDb = (fuma: ReturnType) => ({ count: ( tableName: TName, - options?: { readonly where?: CoreWhere }, + options?: { readonly where?: CoreWhere }, ): Effect.Effect => fuma.use(`${tableName}.count`, (db) => asLooseStorageDb(db).count(tableName, options)), create: ( @@ -777,21 +591,21 @@ const makeCoreDb = (fuma: ReturnType) => ({ .pipe(Effect.asVoid), deleteMany: ( tableName: TName, - options: { readonly where?: CoreWhere } = {}, + options: { readonly where?: CoreWhere } = {}, ): Effect.Effect => fuma.use(`${tableName}.deleteMany`, (db) => asLooseStorageDb(db).deleteMany(tableName, options), ), findFirst: ( tableName: TName, - options: CoreFindFirstOptions, + options: CoreFindFirstOptions, ): Effect.Effect | null, StorageFailure> => fuma.use(`${tableName}.findFirst`, (db) => asLooseStorageDb(db).findFirst(tableName, options), ) as Effect.Effect | null, StorageFailure>, findMany: ( tableName: TName, - options: CoreFindManyOptions = {}, + options: CoreFindManyOptions = {}, ): Effect.Effect[], StorageFailure> => fuma.use(`${tableName}.findMany`, (db) => asLooseStorageDb(db).findMany(tableName, options), @@ -799,7 +613,7 @@ const makeCoreDb = (fuma: ReturnType) => ({ updateMany: ( tableName: TName, options: { - readonly where?: CoreWhere; + readonly where?: CoreWhere; readonly set: Record; }, ): Effect.Effect => @@ -808,9 +622,20 @@ const makeCoreDb = (fuma: ReturnType) => ({ ), }); +type CoreDb = ReturnType; + +// --------------------------------------------------------------------------- +// Plugin storage facade — owner-scoped (was scope-keyed). Reads fall through +// [user, org]; writes/deletes name an explicit owner. +// --------------------------------------------------------------------------- + const pluginStorageEntryFromRow = (row: CoreRow<"plugin_storage">): PluginStorageEntry => ({ - id: row.id, - scopeId: ScopeId.make(row.scope_id), + id: pluginStorageId({ + pluginId: row.plugin_id, + collection: row.collection, + key: row.key, + }), + owner: row.owner as Owner, pluginId: row.plugin_id, collection: row.collection, key: row.key, @@ -832,7 +657,6 @@ const pluginStorageQueryValidationError = ( query: PluginStorageCollectionQueryInput | undefined, ): StorageError | null => { if (!query) return null; - const indexedFields = pluginStorageCollectionIndexedFields(definition); const fields = new Set([ ...Object.keys(query.where ?? {}), @@ -846,7 +670,6 @@ const pluginStorageQueryValidationError = ( }); } } - if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 0)) { return new StorageError({ message: `Plugin storage collection "${definition.name}" received an invalid query limit`, @@ -859,7 +682,6 @@ const pluginStorageQueryValidationError = ( cause: undefined, }); } - return null; }; @@ -886,28 +708,34 @@ const comparePluginStorageValues = (left: unknown, right: unknown): number => { if (leftValue === rightValue) return 0; if (leftValue === null) return -1; if (rightValue === null) return 1; - if (typeof leftValue === "number" && typeof rightValue === "number") { - return leftValue - rightValue; - } - return String(leftValue).localeCompare(String(rightValue)); + return leftValue < rightValue ? -1 : 1; }; const pluginStorageDataField = (data: unknown, field: string): unknown => isPluginStorageRecord(data) ? data[field] : undefined; -const matchesPluginStorageWhereValue = (actual: unknown, expected: unknown): boolean => { - if (!isPluginStorageWhereFilter(expected)) return Object.is(actual, expected); - - if ("eq" in expected && !Object.is(actual, expected.eq)) return false; - if ("in" in expected) { - const values = expected.in; - if (!Array.isArray(values) || !values.some((value) => Object.is(actual, value))) return false; +const matchesWhereOperator = (operator: string, value: unknown, operand: unknown): boolean => { + if (operator === "eq") return comparePluginStorageValues(value, operand) === 0; + if (operator === "in") { + return ( + Array.isArray(operand) && + operand.some((item) => comparePluginStorageValues(value, item) === 0) + ); } - if ("gt" in expected && !(comparePluginStorageValues(actual, expected.gt) > 0)) return false; - if ("gte" in expected && !(comparePluginStorageValues(actual, expected.gte) >= 0)) return false; - if ("lt" in expected && !(comparePluginStorageValues(actual, expected.lt) < 0)) return false; - if ("lte" in expected && !(comparePluginStorageValues(actual, expected.lte) <= 0)) return false; + if (operator === "gt") return comparePluginStorageValues(value, operand) > 0; + if (operator === "gte") return comparePluginStorageValues(value, operand) >= 0; + if (operator === "lt") return comparePluginStorageValues(value, operand) < 0; + if (operator === "lte") return comparePluginStorageValues(value, operand) <= 0; + return false; +}; +const matchesWhereOperators = ( + value: unknown, + filter: Readonly>, +): boolean => { + for (const [operator, operand] of Object.entries(filter)) { + if (!matchesWhereOperator(operator, value, operand)) return false; + } return true; }; @@ -916,38 +744,234 @@ const rowMatchesPluginStorageWhere = ( where: Readonly> | undefined, ): boolean => { if (!where) return true; - return Object.entries(where).every(([field, expected]) => - matchesPluginStorageWhereValue(pluginStorageDataField(row.data, field), expected), - ); + for (const [field, condition] of Object.entries(where)) { + const value = pluginStorageDataField(row.data, field); + if (isPluginStorageWhereFilter(condition)) { + if (!matchesWhereOperators(value, condition)) return false; + } else if (comparePluginStorageValues(value, condition) !== 0) { + return false; + } + } + return true; }; const makePluginStorageFacade = (input: { - readonly core: ReturnType; + readonly core: CoreDb; readonly pluginId: string; - readonly scopeIds: readonly string[]; + readonly owner: OwnerBinding; }): PluginStorageFacade => { - const whereFor = (collection: string, key?: string) => - scopedWhere(input.scopeIds, (b) => + // Owner partitions: org always, plus this subject's user partition. + const readOwners: readonly Owner[] = input.owner.subject == null ? ["org"] : ["user", "org"]; + + const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => { + if (owner === "org") return { owner: "org", subject: ORG_SUBJECT }; + if (input.owner.subject == null) return null; + return { owner: "user", subject: String(input.owner.subject) }; + }; + + const tenant = String(input.owner.tenant); + + const whereFor = + (collection: string, key?: string): CoreWhere => + (b: AnyCb) => b.and( b("plugin_id", "=", input.pluginId), b("collection", "=", collection), key === undefined ? true : b("key", "=", key), - ), - ); + ); + + const whereOwner = (owner: Owner, collection: string, key: string): CoreWhere => { + const os = ownerSubject(owner); + return (b: AnyCb) => + b.and( + b("plugin_id", "=", input.pluginId), + b("collection", "=", collection), + b("key", "=", key), + b("owner", "=", owner), + b("subject", "=", os ? os.subject : ORG_SUBJECT), + ); + }; + + const ownerRank = (owner: Owner): number => readOwners.indexOf(owner); - const sortByScopePrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => + const sortByOwnerPrecedence = (rows: readonly CoreRow<"plugin_storage">[]) => [...rows].sort((left, right) => { - const leftIndex = input.scopeIds.indexOf(left.scope_id); - const rightIndex = input.scopeIds.indexOf(right.scope_id); - return leftIndex - rightIndex || left.key.localeCompare(right.key); + const l = ownerRank(left.owner as Owner); + const r = ownerRank(right.owner as Owner); + return l - r || left.key.localeCompare(right.key); }); const getVisible = (collection: string, key: string) => + input.core.findMany("plugin_storage", { where: whereFor(collection, key) }).pipe( + Effect.map((rows) => sortByOwnerPrecedence(rows)[0] ?? null), + Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null)), + ); + + const getForOwnerImpl = (owner: Owner, collection: string, key: string) => input.core - .findMany("plugin_storage", { where: whereFor(collection, key) }) - .pipe(Effect.map((rows) => sortByScopePrecedence(rows)[0] ?? null)) + .findFirst("plugin_storage", { + where: whereOwner(owner, collection, key), + }) .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null))); + const putImpl = (owner: Owner, collection: string, key: string, data: unknown) => + Effect.gen(function* () { + const os = ownerSubject(owner); + if (!os) { + return yield* new StorageError({ + message: `Cannot write plugin storage for owner "user": executor has no subject.`, + cause: undefined, + }); + } + const existing = yield* input.core.findFirst("plugin_storage", { + where: whereOwner(owner, collection, key), + }); + const now = new Date(); + if (existing) { + yield* input.core.updateMany("plugin_storage", { + where: whereOwner(owner, collection, key), + set: { data, updated_at: now }, + }); + return pluginStorageEntryFromRow({ + ...existing, + data, + updated_at: now, + }); + } + const created = yield* input.core.create("plugin_storage", { + tenant, + owner: os.owner, + subject: os.subject, + plugin_id: input.pluginId, + collection, + key, + data, + created_at: now, + updated_at: now, + }); + return pluginStorageEntryFromRow(created); + }); + + const removeImpl = (owner: Owner, collection: string, key: string) => + Effect.gen(function* () { + const os = ownerSubject(owner); + if (!os) { + return yield* new StorageError({ + message: `Cannot delete plugin storage for owner "user": executor has no subject.`, + cause: undefined, + }); + } + yield* input.core.deleteMany("plugin_storage", { + where: whereOwner(owner, collection, key), + }); + }); + + const keysByCollection = ( + entries: readonly { readonly collection: string; readonly key: string }[], + ) => { + const grouped = new Map>(); + for (const entry of entries) { + const keys = grouped.get(entry.collection); + if (keys) { + keys.add(entry.key); + } else { + grouped.set(entry.collection, new Set([entry.key])); + } + } + return grouped; + }; + + const deleteManyImpl = ( + owner: Owner, + subject: string, + entries: readonly { readonly collection: string; readonly key: string }[], + ) => + Effect.gen(function* () { + for (const [collection, keys] of keysByCollection(entries)) { + const uniqueKeys = [...keys]; + for ( + let offset = 0; + offset < uniqueKeys.length; + offset += PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE + ) { + const batchKeys = uniqueKeys.slice(offset, offset + PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE); + yield* input.core.deleteMany("plugin_storage", { + where: (b) => + b.and( + b("plugin_id", "=", input.pluginId), + b("collection", "=", collection), + b("key", "in", batchKeys), + b("owner", "=", owner), + b("subject", "=", subject), + ), + }); + } + } + }); + + const putManyImpl = ( + owner: Owner, + entries: readonly { + readonly collection: string; + readonly key: string; + readonly data: unknown; + }[], + ) => + Effect.gen(function* () { + const os = ownerSubject(owner); + if (!os) { + return yield* new StorageError({ + message: `Cannot write plugin storage for owner "user": executor has no subject.`, + cause: undefined, + }); + } + const entriesById = new Map( + entries.map((entry) => [ + pluginStorageId({ + pluginId: input.pluginId, + collection: entry.collection, + key: entry.key, + }), + entry, + ]), + ); + const uniqueEntries = [...entriesById.values()]; + if (uniqueEntries.length === 0) return; + + yield* deleteManyImpl(owner, os.subject, uniqueEntries); + + const now = new Date(); + yield* input.core.createMany( + "plugin_storage", + uniqueEntries.map((entry) => ({ + tenant, + owner: os.owner, + subject: os.subject, + plugin_id: input.pluginId, + collection: entry.collection, + key: entry.key, + data: entry.data, + created_at: now, + updated_at: now, + })), + ); + }); + + const removeManyImpl = ( + owner: Owner, + entries: readonly { readonly collection: string; readonly key: string }[], + ) => + Effect.gen(function* () { + const os = ownerSubject(owner); + if (!os) { + return yield* new StorageError({ + message: `Cannot delete plugin storage for owner "user": executor has no subject.`, + cause: undefined, + }); + } + yield* deleteManyImpl(owner, os.subject, entries); + }); + const queryCollection = ( definition: TDefinition, queryInput?: PluginStorageCollectionQueryInput, @@ -964,7 +988,7 @@ const makePluginStorageFacade = (input: { const rows = yield* input.core.findMany("plugin_storage", { where: whereFor(definition.name), }); - const filtered = sortByScopePrecedence(rows) + const filtered = sortByOwnerPrecedence(rows) .filter((row) => queryInput?.keyPrefix === undefined ? true : row.key.startsWith(queryInput.keyPrefix), ) @@ -988,7 +1012,7 @@ const makePluginStorageFacade = (input: { if (compared !== 0) return compared; } return ( - input.scopeIds.indexOf(left.scope_id) - input.scopeIds.indexOf(right.scope_id) || + ownerRank(left.owner as Owner) - ownerRank(right.owner as Owner) || left.key.localeCompare(right.key) ); }) @@ -1011,298 +1035,56 @@ const makePluginStorageFacade = (input: { PluginStorageEntry> | null, StorageFailure >, - getAtScope: (storageInput) => - input.core - .findFirst("plugin_storage", { - where: byScopedId( - storageInput.scope, - pluginStorageId({ - pluginId: input.pluginId, - collection: definition.name, - key: storageInput.key, - }), - ), - }) - .pipe( - Effect.map((row) => - row - ? pluginStorageEntryFromRow>(row) - : null, - ), - ), - list: (storageInput) => - queryCollection(definition, { - keyPrefix: storageInput?.keyPrefix, - }), + getForOwner: (storageInput) => + getForOwnerImpl(storageInput.owner, definition.name, storageInput.key) as Effect.Effect< + PluginStorageEntry> | null, + StorageFailure + >, + list: (storageInput) => queryCollection(definition, { keyPrefix: storageInput?.keyPrefix }), put: (storageInput) => - Effect.gen(function* () { - if (!input.scopeIds.includes(storageInput.scope)) { - return yield* new StorageError({ - message: `Unknown plugin storage target scope: ${storageInput.scope}`, - cause: undefined, - }); - } - const row = yield* input.core.findFirst("plugin_storage", { - where: byScopedId( - storageInput.scope, - pluginStorageId({ - pluginId: input.pluginId, - collection: definition.name, - key: storageInput.key, - }), - ), - }); - if (row) { - const now = new Date(); - yield* input.core.updateMany("plugin_storage", { - where: byScopedId(storageInput.scope, row.id), - set: { - data: storageInput.data, - updated_at: now, - }, - }); - return pluginStorageEntryFromRow({ - ...row, - data: storageInput.data, - updated_at: now, - }); - } - - const now = new Date(); - const created = yield* input.core.create("plugin_storage", { - id: pluginStorageId({ - pluginId: input.pluginId, - collection: definition.name, - key: storageInput.key, - }), - scope_id: storageInput.scope, - plugin_id: input.pluginId, - collection: definition.name, - key: storageInput.key, - data: storageInput.data, - created_at: now, - updated_at: now, - }); - return pluginStorageEntryFromRow(created); - }), - query: (queryInput) => queryCollection(definition, queryInput), - count: (queryInput) => - queryCollection(definition, queryInput).pipe(Effect.map((entries) => entries.length)), - remove: (storageInput) => - input.core.deleteMany("plugin_storage", { - where: byScopedId( - storageInput.scope, - pluginStorageId({ - pluginId: input.pluginId, - collection: definition.name, - key: storageInput.key, - }), - ), - }), + putImpl( + storageInput.owner, + definition.name, + storageInput.key, + storageInput.data, + ) as Effect.Effect< + PluginStorageEntry>, + StorageFailure + >, + query: (storageInput) => queryCollection(definition, storageInput), + count: (storageInput) => + queryCollection(definition, storageInput).pipe(Effect.map((rows) => rows.length)), + remove: (storageInput) => removeImpl(storageInput.owner, definition.name, storageInput.key), }), get: (storageInput) => getVisible(storageInput.collection, storageInput.key), - getAtScope: (storageInput) => - input.core - .findFirst("plugin_storage", { - where: byScopedId( - storageInput.scope, - pluginStorageId({ - pluginId: input.pluginId, - collection: storageInput.collection, - key: storageInput.key, - }), - ), - }) - .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow(row) : null))), + getForOwner: (storageInput) => + getForOwnerImpl(storageInput.owner, storageInput.collection, storageInput.key), list: (storageInput) => - input.core.findMany("plugin_storage", { where: whereFor(storageInput.collection) }).pipe( - Effect.map((rows) => - sortByScopePrecedence(rows) - .filter((row) => - storageInput.keyPrefix === undefined - ? true - : row.key.startsWith(storageInput.keyPrefix), - ) - .map((row) => pluginStorageEntryFromRow(row)), - ), - ), - put: (storageInput) => Effect.gen(function* () { - if (!input.scopeIds.includes(storageInput.scope)) { - return yield* new StorageError({ - message: `Unknown plugin storage target scope: ${storageInput.scope}`, - cause: undefined, - }); - } - const id = pluginStorageId({ - pluginId: input.pluginId, - collection: storageInput.collection, - key: storageInput.key, + const rows = yield* input.core.findMany("plugin_storage", { + where: whereFor(storageInput.collection), }); - const existing = yield* input.core.findFirst("plugin_storage", { - where: byScopedId(storageInput.scope, id), - }); - const now = new Date(); - if (existing) { - yield* input.core.updateMany("plugin_storage", { - where: byScopedId(storageInput.scope, id), - set: { - data: storageInput.data, - updated_at: now, - }, - }); - return pluginStorageEntryFromRow({ - ...existing, - data: storageInput.data, - updated_at: now, - }); - } - const row = yield* input.core.create("plugin_storage", { - id, - scope_id: storageInput.scope, - plugin_id: input.pluginId, - collection: storageInput.collection, - key: storageInput.key, - data: storageInput.data, - created_at: now, - updated_at: now, - }); - return pluginStorageEntryFromRow(row); + return sortByOwnerPrecedence(rows) + .filter((row) => + storageInput.keyPrefix === undefined + ? true + : row.key.startsWith(storageInput.keyPrefix), + ) + .map((row) => pluginStorageEntryFromRow(row)); }), + put: (storageInput) => + putImpl(storageInput.owner, storageInput.collection, storageInput.key, storageInput.data), + putMany: (storageInput) => putManyImpl(storageInput.owner, storageInput.entries), remove: (storageInput) => - input.core.deleteMany("plugin_storage", { - where: byScopedId( - storageInput.scope, - pluginStorageId({ - pluginId: input.pluginId, - collection: storageInput.collection, - key: storageInput.key, - }), - ), - }), + removeImpl(storageInput.owner, storageInput.collection, storageInput.key), + removeMany: (storageInput) => removeManyImpl(storageInput.owner, storageInput.entries), }; }; // --------------------------------------------------------------------------- -// Dynamic-row writers — used by ctx.core.sources.register. Static sources -// never touch these functions. -// --------------------------------------------------------------------------- - -// Upsert shape: delete any existing source + tools + definitions for -// `input.id` before creating fresh rows. Keeps replayable — boot-time -// sync from executor.jsonc can call register() on rows that already -// exist without tripping a UNIQUE constraint. -const writeSourceInput = ( - core: ReturnType, - pluginId: string, - input: SourceInput, -): Effect.Effect => - Effect.gen(function* () { - yield* deleteSourceById(core, input.id, input.scope); - - const now = new Date(); - yield* core.create("source", { - id: input.id, - scope_id: input.scope, - plugin_id: pluginId, - kind: input.kind, - name: input.name, - url: input.url ?? null, - can_remove: input.canRemove ?? true, - can_refresh: input.canRefresh ?? false, - can_edit: input.canEdit ?? false, - created_at: now, - updated_at: now, - }); - - const toolsById = new Map(); - for (const tool of input.tools) { - toolsById.set(`${input.id}.${tool.name}`, tool); - } - const tools = [...toolsById.entries()]; - - if (tools.length > 0) { - yield* core.createMany( - "tool", - tools.map(([id, tool]) => ({ - id, - scope_id: input.scope, - source_id: input.id, - plugin_id: pluginId, - name: tool.name, - description: tool.description, - input_schema: tool.inputSchema ?? null, - output_schema: tool.outputSchema ?? null, - created_at: now, - updated_at: now, - })), - ); - } - }); - -// Delete a source and its tools + definitions at ONE specific scope. -// The helper pins `scope_id = scopeId` so it never widens into a stack-wide -// wipe; a bystander scope's rows with a colliding `source_id` must survive. -const deleteSourceById = ( - core: ReturnType, - sourceId: string, - scopeId: string, -): Effect.Effect => - Effect.gen(function* () { - yield* core.deleteMany("tool", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scopeId)), - }); - yield* core.deleteMany("definition", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scopeId)), - }); - yield* core.deleteMany("source", { - where: byScopedId(scopeId, sourceId), - }); - }); - -const writeDefinitions = ( - core: ReturnType, - pluginId: string, - input: DefinitionsInput, -): Effect.Effect => - Effect.gen(function* () { - // Pin the delete to `input.scope` so an inner-scope writer cannot remove - // outer-scope definitions for the same source id. - yield* core.deleteMany("definition", { - where: (b) => b.and(b("source_id", "=", input.sourceId), b("scope_id", "=", input.scope)), - }); - const entries = Object.entries(input.definitions); - if (entries.length === 0) return; - const now = new Date(); - yield* core.createMany( - "definition", - entries.map(([name, schema]) => ({ - id: `${input.sourceId}.${name}`, - scope_id: input.scope, - source_id: input.sourceId, - plugin_id: pluginId, - name, - schema: schema as Record, - created_at: now, - })), - ); - }); - -// --------------------------------------------------------------------------- -// Filtering — shared between dynamic (DB) and static (in-memory) pools -// so `tools.list({ query, sourceId })` matches across both. +// Approval argument preview // --------------------------------------------------------------------------- -const toolMatchesFilter = (tool: ToolView, filter: ToolListFilter): boolean => { - if (filter.sourceId && tool.sourceId !== filter.sourceId) return false; - if (filter.query) { - const q = filter.query.toLowerCase(); - const hay = `${tool.name} ${tool.description}`.toLowerCase(); - if (!hay.includes(q)) return false; - } - return true; -}; - const approvalArgumentPreview = (args: unknown): string => { const text = JSON.stringify(args ?? {}, null, 2) ?? "null"; return text.length > MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS @@ -1321,17 +1103,45 @@ interface StaticTools { readonly ctx: PluginCtx; } -interface StaticSources { - readonly source: StaticSourceDecl; - readonly pluginId: string; -} - interface PluginRuntime { readonly plugin: AnyPlugin; readonly storage: unknown; readonly ctx: PluginCtx; } +const EXECUTOR_SOURCE_ID = "executor"; +const EXECUTOR_SOURCE: StaticSourceDecl = { + id: EXECUTOR_SOURCE_ID, + kind: "built-in", + name: "Executor", + canRemove: false, + canRefresh: false, + canEdit: false, + tools: [], +}; + +const isReadonlyRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null; + +type StandardJsonSchemaSide = "input" | "output"; +type StandardJsonSchemaFns = { + readonly input?: (options: { readonly target: "draft-07" }) => unknown; + readonly output?: (options: { readonly target: "draft-07" }) => unknown; +}; + +const staticToolSchemaRoot = ( + schema: StaticToolDecl["inputSchema"] | StaticToolDecl["outputSchema"], + side: StandardJsonSchemaSide, +): unknown | undefined => { + if (!schema) return undefined; + const standard = isReadonlyRecord(schema) ? schema["~standard"] : undefined; + if (!isReadonlyRecord(standard)) return schema; + const jsonSchema = standard["jsonSchema"]; + if (!isReadonlyRecord(jsonSchema)) return schema; + const materialize = (jsonSchema as StandardJsonSchemaFns)[side]; + return typeof materialize === "function" ? materialize({ target: "draft-07" }) : jsonSchema; +}; + export const createExecutor = ( config: ExecutorConfig, ): Effect.Effect, StorageFailure> => @@ -1340,21 +1150,45 @@ export const createExecutor = { + if (owner === "org") return { tenant, owner, subject: ORG_SUBJECT }; + if (subject == null) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: programmer error caught and surfaced as StorageError below by callers + throw new StorageError({ + message: `Cannot target owner "user": executor has no subject.`, + cause: undefined, + }); + } + return { tenant, owner, subject }; + }; + + const requireUserSubject = (owner: Owner): Effect.Effect => + owner === "user" && subject == null + ? Effect.fail( + new StorageError({ + message: `Cannot target owner "user": executor has no subject.`, + cause: undefined, + }), + ) + : Effect.void; - // Built-in core-tools plugin: contributes scopes.list / secrets.list / - // secrets.create static tools so agents can manage executor primitives - // without the host wiring it explicitly. Opt-in via `coreTools` config. + // Built-in core-tools plugin: agent-facing static tools over the v2 surface. const plugins: readonly AnyPlugin[] = config.coreTools ? ([ - coreToolsPlugin({ webBaseUrl: config.coreTools.webBaseUrl }), + coreToolsPlugin({ + webBaseUrl: config.coreTools.webBaseUrl, + includeProviders: config.coreTools.includeProviders, + }), ...userPlugins, ] as readonly AnyPlugin[]) : (userPlugins as readonly AnyPlugin[]); @@ -1374,14 +1208,13 @@ export const createExecutor = { validateExecutorDbTables(tables, rootDbUntyped.internal.tables); - validateExecutorScopePolicyTables(rootDbUntyped.internal.tables); + validateExecutorOwnerPolicyTables(rootDbUntyped.internal.tables); }, catch: (cause) => storageFailureFromUnknown("Failed to validate executor tables", cause), }); - const scopeIds = scopes.map((s) => String(s.id)); - const rootDb = withQueryContext(rootDbUntyped, { - allowedScopeIds: new Set(scopeIds), - } satisfies ExecutorScopePolicyContext); + + const ownerContext: ExecutorOwnerPolicyContext = { tenant, subject }; + const rootDb = withQueryContext(rootDbUntyped, ownerContext); const fuma = makeFumaClient(rootDb); const core = makeCoreDb(fuma); const blobs = makeFumaBlobStore(fuma); @@ -1389,3052 +1222,1789 @@ export const createExecutor = (); - const staticSources = new Map(); - - // Per-plugin runtime state. const runtimes = new Map(); - // Secret providers keyed by `provider.key`. - const secretProviders = new Map(); - // Connection providers keyed by `provider.key` — drive the refresh - // lifecycle for connection-owned tokens. - const connectionProviders = new Map(); - const resolveConnectionProvider = (key: string): ConnectionProvider | undefined => - connectionProviders.get(key); - // In-flight refresh dedup. `connectionsAccessToken` stamps a - // `Deferred` here before calling the provider's `refresh`; parallel - // callers that walk in while a refresh is still running observe - // the same Deferred and await its resolution instead of hitting - // the AS a second time. The map is mutated under a semaphore so - // check-or-register is atomic under fiber interleavings. - const refreshInFlight = new Map< - string, - Deferred.Deferred< - string, - | ConnectionNotFoundError - | ConnectionProviderNotRegisteredError - | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError - | ConnectionRefreshError - | StorageFailure - > - >(); - const refreshInFlightLock = Semaphore.makeUnsafe(1); - const extensions: Record = {}; - - // ------------------------------------------------------------------ - // Secrets facade — fast path is the core `secret` routing table - // (explicit set()s, keychain entries, etc). Fallback is a walk - // across providers that implement `list()`, because those are the - // providers that own their own inventories (1password, file-secrets, - // workos-vault, env) and enumerate-without-register. Providers - // without a list() implementation (keychain) never hit the fallback - // walk because their secrets must be registered through set() to - // be known at all. - // - // Multi-scope behavior: the routing-table lookup pulls every row - // for this id across the scope stack in a single `IN (...)` query, - // then sorts innermost-first so a secret registered in a deeper - // scope shadows one with the same id at a shallower scope (e.g. a - // user's personal OAuth token wins over an org-wide one). Provider - // calls stay sequential — scope-partitioning providers (workos-vault, - // 1password-per-vault) have to be asked per scope because the object - // name includes the scope — but they're bounded by the number of - // registered rows for this id, not by scope-stack depth. The - // provider-enumeration fallback is scope-agnostic: providers like - // env or 1password don't partition their inventory by executor scope. - const scopePrecedence = new Map(); - scopeIds.forEach((s, i) => scopePrecedence.set(s, i)); - - // Rank a row by how close its `scope_id` sits to the innermost scope. - // Rows whose scope isn't in the stack get pushed to the end (they - // should only arrive through explicit scope predicates, but guarding here - // means a stray row can't silently win). - const rowScopeId = (row: { readonly scope_id: unknown }) => - typeof row.scope_id === "string" ? row.scope_id : null; - const scopeRank = (row: { readonly scope_id: unknown }) => { - const scopeId = rowScopeId(row); - return scopeId === null ? Infinity : (scopePrecedence.get(scopeId) ?? Infinity); - }; - - // Pick the innermost-scope row from a scoped Fuma query. Callers that - // need one logical row query the whole visible scope stack and resolve - // shadowing here. - const findInnermost = (rows: readonly T[]): T | null => { - if (rows.length === 0) return null; - let winner: T | undefined; - let best = Infinity; - for (const row of rows) { - const rank = scopeRank(row); - if (rank < best) { - best = rank; - winner = row; - } + // Credential providers keyed by `provider.key`, in registration order. + const credentialProviders = new Map(); + const credentialProviderOrder: string[] = []; + + const staticToolOwner = (): Owner => (subject == null ? "org" : "user"); + const staticToolConnection = (source: StaticSourceDecl): ConnectionName => + ConnectionName.make(source.id === EXECUTOR_SOURCE_ID ? "coreTools" : "static"); + + const staticSources = (): readonly StaticSourceDecl[] => { + const byId = new Map(); + for (const entry of staticTools.values()) { + if (!byId.has(entry.source.id)) byId.set(entry.source.id, entry.source); } - return winner ?? null; + return [...byId.values()]; }; - const filterUsagesToScopeStack = (usages: readonly Usage[]): readonly Usage[] => - usages.filter((usage) => scopeIds.includes(usage.scopeId)); - - const secretRowsForId = (id: string): Effect.Effect => - core.findMany("secret", { where: scopedWhere(scopeIds, byId(id)) }) as Effect.Effect< - readonly SecretRow[], - StorageFailure - >; + const staticSourceToIntegration = (source: StaticSourceDecl): Integration => ({ + slug: IntegrationSlug.make(source.id), + description: source.name, + kind: source.kind, + canRemove: source.canRemove ?? false, + canRefresh: source.canRefresh ?? false, + authMethods: [], + }); - const resolveSecretValueFromRows = ( - id: string, - rows: readonly SecretRow[], - ): Effect.Effect => - Effect.gen(function* () { - const ordered = [...rows].sort((a, b) => scopeRank(a) - scopeRank(b)); - for (const row of ordered) { - const provider = secretProviders.get(row.provider); - if (!provider) continue; - const value = yield* provider.get(id, row.scope_id); - if (value !== null) return value; - } + const staticToolToTool = (entry: StaticTools): Tool => ({ + address: ToolAddress.make(`${entry.source.id}.${entry.tool.name}`), + owner: staticToolOwner(), + integration: IntegrationSlug.make(entry.source.id), + connection: staticToolConnection(entry.source), + name: ToolName.make(entry.tool.name), + pluginId: entry.pluginId, + description: entry.tool.description, + inputSchema: staticToolSchemaRoot(entry.tool.inputSchema, "input"), + outputSchema: staticToolSchemaRoot(entry.tool.outputSchema, "output"), + annotations: entry.tool.annotations, + static: true, + }); - // Fallback: ask enumerating providers in registration order. First - // non-null wins. Providers that throw - // are treated as "don't have it" so one flaky provider can't - // block resolution via others. Scope-partitioning providers - // get asked at the innermost scope as a display default — the - // enumeration fallback doesn't know which scope the value - // lives in; flat providers ignore the arg. - const fallbackScope = scopeIds[0]!; - const candidates = [...secretProviders.values()].filter( - (p) => p.list && p.allowFallback !== false, + const registerCredentialProvider = ( + provider: CredentialProvider, + sourceLabel: string, + ): Effect.Effect => { + const key = String(provider.key); + if (credentialProviders.has(key)) { + return Effect.fail( + new StorageError({ + message: `Duplicate credential provider key: ${key} (from ${sourceLabel})`, + cause: undefined, + }), ); - for (const provider of candidates) { - const value = yield* provider - .get(id, fallbackScope) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (value !== null) return value; - } - return null; - }); - - const secretsGet = ( - id: string, - ): Effect.Effect => - Effect.gen(function* () { - // Connection-owned token rows are internal plumbing; public secret - // resolution must not expose them even if a token secret id is leaked. - const rows = yield* secretRowsForId(id); - const owned = rows.find((row) => row.owned_by_connection_id); - const ownedByConnectionId = owned?.owned_by_connection_id; - if (ownedByConnectionId) { - return yield* new SecretOwnedByConnectionError({ - secretId: SecretId.make(id), - connectionId: ConnectionId.make(ownedByConnectionId), - }); - } - return yield* resolveSecretValueFromRows(id, rows); - }); + } + credentialProviders.set(key, provider); + credentialProviderOrder.push(key); + return Effect.void; + }; - const secretsGetResolved = ( - id: string, - ): Effect.Effect< - { readonly value: string; readonly scopeId: string | null } | null, - StorageFailure - > => - Effect.gen(function* () { - const rows = yield* secretRowsForId(id); - const ordered = [...rows].sort((a, b) => scopeRank(a) - scopeRank(b)); - for (const row of ordered) { - if (row.owned_by_connection_id) continue; - const value = yield* resolveSecretValueAtScope(row, id); - if (value !== null) return { value, scopeId: row.scope_id }; - } - const value = yield* resolveSecretValueFromRows(id, []); - return value === null ? null : { value, scopeId: null }; - }); + // Config-level providers register first so the default store prefers them. + for (const provider of config.providers ?? []) { + yield* registerCredentialProvider(provider, "config"); + } - const resolveSecretValueAtScope = ( - row: SecretRow | null, - id: string, - ): Effect.Effect => - Effect.gen(function* () { - if (!row) return null; - const provider = secretProviders.get(row.provider); - if (!provider) return null; - return yield* provider.get(id, row.scope_id); - }); + const defaultWritableProvider = (): CredentialProvider | null => { + for (const key of credentialProviderOrder) { + const provider = credentialProviders.get(key); + if (provider?.writable) return provider; + } + return null; + }; - const secretsGetAtScope = ( - id: string, - scope: string, - ): Effect.Effect => - Effect.gen(function* () { - yield* assertScopeInStack("secret get scope", scope); - const row = yield* findSecretRowAtScope({ - secretId: id, - scopeId: scope, - }); - if (row?.owned_by_connection_id) { - return yield* new SecretOwnedByConnectionError({ - secretId: SecretId.make(id), - connectionId: ConnectionId.make(row.owned_by_connection_id), - }); - } - return yield* resolveSecretValueAtScope(row, id); - }); + const extensions: Record = {}; - const connectionSecretGetAtScope = ( - id: string, - scope: string, - ): Effect.Effect => - Effect.gen(function* () { - yield* assertScopeInStack("connection secret get scope", scope); - const row = yield* findSecretRowAtScope({ - secretId: id, - scopeId: scope, - }); - return yield* resolveSecretValueAtScope(row, id); - }); + // ------------------------------------------------------------------ + // Owner condition builders. The owner policy already restricts reads to + // (tenant, org|this-subject); `byOwner` narrows to one explicit owner. + // ------------------------------------------------------------------ - const secretRouteHasBackingValue = (row: SecretRow) => { - const provider = secretProviders.get(row.provider); - if (!provider?.has) return Effect.succeed(true); - return provider.has(row.id, row.scope_id).pipe(Effect.catch(() => Effect.succeed(false))); - }; + const byOwner = + (owner: Owner): CoreWhere => + (b: AnyCb) => { + const keys = owner === "org" ? ORG_SUBJECT : (subject ?? "__none__"); + return b.and(b("owner", "=", owner), b("subject", "=", keys)); + }; - const secretsSet = (input: SetSecretInput): Effect.Effect => - Effect.gen(function* () { - // Validate the write target before we touch the provider. - if (!scopeIds.includes(input.scope)) { - return yield* new StorageError({ - message: - `secrets.set targets scope "${input.scope}" which is not ` + - `in the executor's scope stack [${scopeIds.join(", ")}].`, - cause: undefined, - }); - } + // ------------------------------------------------------------------ + // Credential resolution + // ------------------------------------------------------------------ - // Pick provider: explicit or first-writable. Misconfiguration - // (unknown provider, no writable provider, read-only provider) - // is a host setup bug — surface as `StorageError` so it lands - // as a captured InternalError(traceId) at the SDK boundary. - let target: SecretProvider | undefined; - if (input.provider) { - target = secretProviders.get(input.provider); - if (!target) { - return yield* new StorageError({ - message: `Unknown secret provider: ${input.provider}`, - cause: undefined, - }); - } - } else { - for (const provider of secretProviders.values()) { - if (provider.writable && provider.set) { - target = provider; - break; - } - } - if (!target) { - return yield* new StorageError({ - message: "No writable secret providers registered", - cause: undefined, - }); - } - } - if (!target.writable || !target.set) { - return yield* new StorageError({ - message: `Secret provider "${target.key}" is read-only`, - cause: undefined, - }); - } + const findConnectionRow = ( + ref: ConnectionRef, + ): Effect.Effect => + core.findFirst("connection", { + where: (b: AnyCb) => + b.and( + byOwner(ref.owner)(b), + b("integration", "=", String(ref.integration)), + b("name", "=", String(ref.name)), + ), + }); - yield* target.set(input.id, input.value, input.scope); + // In-flight refresh gate — concurrent resolves of the same connection share + // one refresh (mirrors v1's refresh deferred-map) so we never fire two + // refresh-token grants for the same connection in parallel (the AS rotates + // the refresh token; the second request would race on a consumed token). + const refreshInFlight = new Map< + string, + Effect.Effect + >(); - // Upsert metadata row in the core `secret` table at the - // caller-named scope. Pin the delete to `scope_id = input.scope` - // so a personal override never deletes an org-wide secret with - // the same id. - const now = new Date(); - yield* core.deleteMany("secret", { - where: byScopedId(input.scope, input.id), - }); - yield* core.create("secret", { - id: input.id, - scope_id: input.scope, - name: input.name, - provider: target.key, - owned_by_connection_id: null, - created_at: now, - }); + const connectionKey = (row: ConnectionRow): string => + `${row.owner}:${row.subject}:${row.integration}:${row.name}`; - return SecretRef.make({ - id: input.id, - scopeId: input.scope, - name: input.name, - provider: target.key, - createdAt: now, - }); + const loadOAuthClientRow = ( + owner: Owner, + slug: string, + ): Effect.Effect => + core.findFirst("oauth_client", { + where: (b: AnyCb) => b.and(byOwner(owner)(b), b("slug", "=", slug)), }); - // Fan out across every plugin that contributes `usagesForSecret`. Each - // plugin queries its own normalized columns with explicit scope filters. - // - // The display path (`secretsUsages` / `connectionsUsages` from the API) - // calls `*Lenient`: per-plugin errors become a logWarning so one buggy - // plugin can't break the UI footer. The delete RESTRICT path - // (`secretsRemove` / `connectionsRemove`) calls `*Strict`: per-plugin - // errors fail the whole call so a transient plugin failure can't be - // mistaken for "no usages" and let through a delete that creates - // dangling refs. - const secretsUsagesStrict = (id: string): Effect.Effect => - Effect.gen(function* () { - const secretId = SecretId.make(id); - const coreUsages = yield* credentialBindingUsagesForSecret(id); - const perPlugin = yield* Effect.all( - [...runtimes.values()] - .filter((r) => r.plugin.usagesForSecret) - .map((r) => - r.plugin.usagesForSecret!({ - ctx: r.ctx, - args: { secretId }, + // Perform the actual refresh-token grant and persist the rotated material. + const performTokenRefresh = ( + row: ConnectionRow, + provider: CredentialProvider, + ): Effect.Effect => + Effect.gen(function* () { + const owner = row.owner as Owner; + const reauth = (message: string): CredentialResolutionError => + new CredentialResolutionError({ + owner, + integration: IntegrationSlug.make(row.integration), + name: ConnectionName.make(row.name), + message, + reauthRequired: true, + }); + + // Load the backing app by the owner STORED on the connection (a Personal + // connection may be backed by a shared Workspace app) — no derivation. + const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner; + const clientRow = yield* loadOAuthClientRow(clientOwner, String(row.oauth_client)); + if (!clientRow) { + return yield* reauth(`OAuth client "${row.oauth_client}" is no longer registered.`); + } + + // The secret is stored in the provider (a vault item id), not inline. + const clientSecret = clientRow.client_secret_item_id + ? ((yield* provider.get(ProviderItemId.make(String(clientRow.client_secret_item_id)))) ?? + "") + : ""; + // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a + // refresh must not exceed the originally-granted scope). Empty → omit + // the param, which the AS treats as "same scopes as granted". + const grantedScopes = row.oauth_scope + ? String(row.oauth_scope).split(/\s+/).filter(Boolean) + : []; + + // client_credentials (machine-to-machine) has NO refresh token — the + // token is RE-MINTED from the client id/secret. The authorization_code + // path below needs a stored refresh token. Branching on grant here is + // what keeps a client_credentials connection (e.g. DealCloud) from + // demanding a re-auth on a credential that has no human to re-auth. + const token = + String(clientRow.grant) === "client_credentials" + ? yield* exchangeClientCredentials({ + tokenUrl: String(clientRow.token_url), + clientId: String(clientRow.client_id), + clientSecret, + scopes: grantedScopes, + resource: clientRow.resource ? String(clientRow.resource) : undefined, + endpointUrlPolicy: config.oauthEndpointUrlPolicy, }).pipe( + // A client_credentials failure is never a rotated-refresh-token + // problem, so do NOT map invalid_grant → reauth. Surface as a + // StorageError; the in-flight gate clears on settle, so the next + // invoke retries (handles transient AS/network blips). Effect.mapError( - (cause): StorageFailure => + (cause) => new StorageError({ - message: `usagesForSecret failed for plugin ${r.plugin.id}`, + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message` + message: `Client-credentials token request failed: ${cause.message}`, cause, }), ), - ), - ), - { concurrency: "unbounded" }, - ); - return filterUsagesToScopeStack([...coreUsages, ...perPlugin.flat()]); - }); - - const secretsUsages = (id: string): Effect.Effect => - Effect.gen(function* () { - const secretId = SecretId.make(id); - const coreUsages = yield* credentialBindingUsagesForSecret(id); - const perPlugin = yield* Effect.all( - [...runtimes.values()] - .filter((r) => r.plugin.usagesForSecret) - .map((r) => - r.plugin.usagesForSecret!({ - ctx: r.ctx, - args: { secretId }, - }).pipe( - Effect.catchCause((cause: unknown) => - Effect.logWarning(`usagesForSecret failed for plugin ${r.plugin.id}`, cause).pipe( - Effect.as([] as readonly Usage[]), + ) + : yield* Effect.gen(function* () { + if (!row.refresh_item_id) { + return yield* reauth("No refresh token is stored for this connection."); + } + const refreshToken = yield* provider.get(ProviderItemId.make(row.refresh_item_id)); + if (!refreshToken) { + return yield* reauth("Stored refresh token could not be resolved."); + } + return yield* refreshAccessToken({ + tokenUrl: String(clientRow.token_url), + clientId: String(clientRow.client_id), + clientSecret, + refreshToken, + scopes: grantedScopes, + // RFC 8707: keep the re-minted token bound to the same resource + // (MCP servers require this on refresh). + resource: clientRow.resource ? String(clientRow.resource) : undefined, + endpointUrlPolicy: config.oauthEndpointUrlPolicy, + }).pipe( + Effect.mapError((cause) => + cause.error === "invalid_grant" + ? reauth( + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message` + `OAuth token refresh was rejected (invalid_grant): ${cause.message}`, + ) + : new StorageError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuth2Error carries a typed `message` + message: `OAuth token refresh failed: ${cause.message}`, + cause, + }), ), - ), - ), - ), - { concurrency: "unbounded" }, - ); - return filterUsagesToScopeStack([...coreUsages, ...perPlugin.flat()]); - }); + ); + }); - const connectionsUsagesStrict = (id: string): Effect.Effect => - Effect.gen(function* () { - const connectionId = ConnectionId.make(id); - const coreUsages = yield* credentialBindingUsagesForConnection(id); - const perPlugin = yield* Effect.all( - [...runtimes.values()] - .filter((r) => r.plugin.usagesForConnection) - .map((r) => - r.plugin.usagesForConnection!({ - ctx: r.ctx, - args: { connectionId }, - }).pipe( - Effect.mapError( - (cause): StorageFailure => - new StorageError({ - message: `usagesForConnection failed for plugin ${r.plugin.id}`, - cause, - }), - ), - ), - ), - { concurrency: "unbounded" }, - ); - return filterUsagesToScopeStack([...coreUsages, ...perPlugin.flat()]); - }); + if (provider.set) { + // OAuth is always single-input: the access token lives in the `token` + // item. Fall back to a deterministic id if the map is somehow empty. + const tokenItemId = + connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ?? + `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`; + yield* provider.set(ProviderItemId.make(tokenItemId), token.access_token); + if (token.refresh_token && row.refresh_item_id) { + yield* provider.set(ProviderItemId.make(row.refresh_item_id), token.refresh_token); + } + } - const connectionsUsages = (id: string): Effect.Effect => - Effect.gen(function* () { - const connectionId = ConnectionId.make(id); - const coreUsages = yield* credentialBindingUsagesForConnection(id); - const perPlugin = yield* Effect.all( - [...runtimes.values()] - .filter((r) => r.plugin.usagesForConnection) - .map((r) => - r.plugin.usagesForConnection!({ - ctx: r.ctx, - args: { connectionId }, - }).pipe( - Effect.catchCause((cause: unknown) => - Effect.logWarning( - `usagesForConnection failed for plugin ${r.plugin.id}`, - cause, - ).pipe(Effect.as([] as readonly Usage[])), - ), - ), + const nextExpiresAt = + typeof token.expires_in === "number" ? Date.now() + token.expires_in * 1000 : null; + const set: Record = { + expires_at: nextExpiresAt, + updated_at: new Date(), + }; + if (token.scope !== undefined) set.oauth_scope = token.scope; + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(owner)(b), + b("integration", "=", String(row.integration)), + b("name", "=", String(row.name)), ), - { concurrency: "unbounded" }, - ); - return filterUsagesToScopeStack([...coreUsages, ...perPlugin.flat()]); + set, + }); + + return token.access_token; }); - const secretsRemove = ( - input: RemoveSecretInput, - ): Effect.Effect => + const refreshConnectionToken = ( + row: ConnectionRow, + provider: CredentialProvider, + ): Effect.Effect => + // Share a single refresh per connection so concurrent resolves of the same + // connection all await one refresh-token grant (the AS rotates the refresh + // token; parallel grants would race on a consumed token — v1's refresh + // deferred-map). The gate is cleared once the refresh settles so a later + // expiry can refresh again. + Effect.gen(function* () { + const key = connectionKey(row); + const existing = refreshInFlight.get(key); + if (existing) return yield* existing; + // `Effect.cached` memoizes the grant onto a deferred: it runs once and + // replays to every awaiter sharing this entry. + const memoized = yield* Effect.cached(performTokenRefresh(row, provider)); + const gated = memoized.pipe( + Effect.ensuring(Effect.sync(() => refreshInFlight.delete(key))), + ); + // Re-check after building (a peer fiber may have registered first while + // we built ours) so everyone converges on the same shared grant. + const winner = refreshInFlight.get(key) ?? gated; + if (winner === gated) refreshInFlight.set(key, gated); + return yield* winner; + }); + + // Resolve every named input of a connection (`variable → value`). A + // single-secret connection yields `{ token: }`; an apiKey method with + // two distinct inputs yields one entry per variable. OAuth connections refresh + // first (always single-input → `{ token: }`). + const resolveConnectionValues = ( + row: ConnectionRow, + ): Effect.Effect, StorageFailure | CredentialResolutionError> => Effect.gen(function* () { - const id = input.id; - const targetScope = input.targetScope; - if (!scopeIds.includes(targetScope)) { - return yield* new StorageError({ - message: - `secret remove targetScope "${targetScope}" is not in the executor's scope stack ` + - `[${scopeIds.join(", ")}].`, - cause: undefined, + const provider = credentialProviders.get(row.provider); + if (!provider) { + return yield* new CredentialProviderNotRegisteredError({ + provider: ProviderKey.make(row.provider), }); } - - // Remove is target-scope aware: drop only the explicitly named - // scope row. Removing a user-scope override on a secret that also - // has an org-scope default should reveal the org default, not wipe - // it. If no core row exists at the target scope, provider cleanup - // is still scoped to the explicit target for provider-enumerated - // secrets, but core metadata never falls through to an outer row. - const rows = yield* core.findMany("secret", { - where: scopedWhere(scopeIds, byId(id)), - }); - const target = rows.find((row) => row.scope_id === targetScope); - // Refuse to delete connection-owned secrets. The connection owns - // the lifecycle — callers must go through connections.remove. - if (target && target.owned_by_connection_id) { - return yield* new SecretOwnedByConnectionError({ - secretId: SecretId.make(id), - connectionId: ConnectionId.make(target.owned_by_connection_id), - }); + // OAuth connections refresh their access token before resolving when + // it has expired (or is within the skew window). + const expiresAt = row.expires_at == null ? null : Number(row.expires_at); + if (row.oauth_client != null && shouldRefreshToken({ expiresAt })) { + const access = yield* refreshConnectionToken(row, provider); + return { [PRIMARY_INPUT_VARIABLE]: access }; } - // RESTRICT: source/binding rows are pinned to the credential row's - // scope. A same-id row in an outer scope does not satisfy a binding - // written at the target scope, so the delete gate filters usages to - // the exact row being removed. - if (target) { - const usages = (yield* secretsUsagesStrict(id)).filter( - (usage) => usage.scopeId === targetScope, - ); - if (usages.length > 0) { - return yield* new SecretInUseError({ - secretId: SecretId.make(id), - usageCount: usages.length, - }); - } + const out: Record = {}; + for (const [variable, itemId] of Object.entries(connectionItemIds(row))) { + out[variable] = yield* provider.get(ProviderItemId.make(itemId)); } + return out; + }).pipe( + // CredentialProviderNotRegisteredError is part of CredentialResolution + // for ctx.connections.resolveValue's StorageFailure channel — fold it. + Effect.catchTag("CredentialProviderNotRegisteredError", (err) => + Effect.fail( + new StorageError({ + message: `Credential provider "${err.provider}" is not registered.`, + cause: err, + }), + ), + ), + ); - const deleters = [...secretProviders.values()].filter( - (p): p is typeof p & { delete: NonNullable } => - !!(p.writable && p.delete), - ); - yield* Effect.all( - deleters.map((p) => p.delete(id, targetScope)), - { concurrency: "unbounded" }, - ); - - if (target) { - yield* core.deleteMany("secret", { - where: byScopedId(targetScope, id), - }); - } - }); + /** The primary (`token`) value — the public seam for OAuth + single-input + * callers that only ever need one value. */ + const resolveConnectionValue = ( + row: ConnectionRow, + ): Effect.Effect => + resolveConnectionValues(row).pipe( + Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null), + ); - // List is a union of two sources of truth: - // - // 1. Core `secret` rows — secrets explicitly registered via - // executor.secrets.set(...). These carry their pinned provider - // and are authoritative for routing (get() uses them). - // 2. Each provider's own `list()` — for read-only or - // already-populated providers (1password, file-secrets, - // workos-vault, env), the provider enumerates what's actually - // in its backend. These show up in the list even if the user - // never called set() through the executor. - // - // Dedupe by secret id; core rows win over provider-enumerated ones - // so that routing information in the core table is authoritative. - // Providers without a list() method (e.g. keychain) contribute - // only via the core table path. - // - // Multi-scope: core rows from any scope in the stack show up, each - // tagged with its own `scope_id`. When the same id appears in multiple scopes, the - // innermost wins — same rule as `secretsGet`. Provider-enumerated - // entries don't know what scope they belong to and are attributed - // to the innermost scope as a display default. - const secretsList = (): Effect.Effect => + const resolveConnectionValueByRef = ( + ref: ConnectionRef, + ): Effect.Effect => Effect.gen(function* () { - const byId = new Map(); - - // Core routing rows first. Resolve collisions using the caller's - // precedence order (innermost first). Rows owned by a connection - // are filtered out — the user sees the Connection entry, not its - // backing token secrets. Their ids go in a deny-set so provider - // `list()` results for the same id can't leak them back in below. - const allRows = yield* core.findMany("secret", { where: scopedWhere(scopeIds) }); - const rows = allRows.filter((r) => !r.owned_by_connection_id); - const pick = (row: (typeof rows)[number]) => { - const existing = byId.get(row.id); - const incomingScope = row.scope_id; - const incomingRank = scopeRank(row); - if (existing) { - const existingRank = scopePrecedence.get(existing.scopeId) ?? Infinity; - if (existingRank <= incomingRank) return; - } - byId.set( - row.id, - SecretRef.make({ - id: SecretId.make(row.id), - scopeId: ScopeId.make(incomingScope), - name: row.name, - provider: row.provider, - createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), + const row = yield* findConnectionRow(ref); + if (!row) return null; + return yield* resolveConnectionValue(row); + }).pipe( + // The plugin-facing contract (`ctx.connections.resolveValue`, `getValue`) + // is `StorageFailure`-typed; fold a reauth-required resolution failure + // into a StorageError so the public surface stays stable. + Effect.catchTag("CredentialResolutionError", (err) => + Effect.fail( + new StorageError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: CredentialResolutionError carries a typed `message` field + message: err.message, + cause: err, }), - ); - }; - for (const row of rows) { - const hasBackingValue = yield* secretRouteHasBackingValue(row); - if (hasBackingValue) pick(row); - } - - // Don't let provider-enumerated entries resurrect ids that - // belong to a connection-owned core row. - const connectionOwnedIds = new Set( - allRows.filter((r) => r.owned_by_connection_id).map((r) => r.id), - ); - // Attribute provider-listed entries to the innermost scope as - // a display default — providers like 1password and env don't - // partition their inventory by executor scope. - const innermostScopeId = scopeIds[0]; - if (innermostScopeId !== undefined) { - for (const [key, provider] of secretProviders) { - if (!provider.list) continue; - const entries = yield* provider - .list() - .pipe(Effect.catch(() => Effect.succeed([] as const))); - for (const entry of entries) { - if (byId.has(entry.id)) continue; - if (connectionOwnedIds.has(entry.id)) continue; - byId.set( - entry.id, - SecretRef.make({ - id: SecretId.make(entry.id), - scopeId: ScopeId.make(innermostScopeId), - name: entry.name, - provider: key, - createdAt: new Date(0), - }), - ); - } - } - } + ), + ), + ); - return Array.from(byId.values()); - }); + // ------------------------------------------------------------------ + // Integrations + // ------------------------------------------------------------------ - const secretsListAll = (): Effect.Effect => - Effect.gen(function* () { - const allRows = yield* core.findMany("secret", { where: scopedWhere(scopeIds) }); - const coreIds = new Set(); - const refs: SecretRef[] = []; - - for (const row of allRows) { - coreIds.add(row.id); - if (row.owned_by_connection_id) continue; - const hasBackingValue = yield* secretRouteHasBackingValue(row); - if (!hasBackingValue) continue; - refs.push( - SecretRef.make({ - id: SecretId.make(row.id), - scopeId: ScopeId.make(row.scope_id), - name: row.name, - provider: row.provider, - createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), - }), - ); - } + const findIntegrationRow = ( + slug: IntegrationSlug, + ): Effect.Effect => + core.findFirst("integration", { + where: (b: AnyCb) => b("slug", "=", String(slug)), + }); + + // Project a row's stored config into declared auth methods via the owning + // plugin's `describeAuthMethods` hook. The hook is plugin-authored, so a + // throw (malformed config it didn't guard) degrades to `[]` rather than + // failing the catalog read. + const describeAuthMethodsForRow = (row: IntegrationRow): readonly AuthMethodDescriptor[] => { + const runtime = runtimes.get(row.plugin_id); + const describe = runtime?.plugin.describeAuthMethods; + if (!describe) return []; + const record = rowToIntegrationRecord(row); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read + try { + return describe(record); + } catch { + return []; + } + }; - return refs.sort((a, b) => { - const rank = - (scopePrecedence.get(a.scopeId) ?? Infinity) - - (scopePrecedence.get(b.scopeId) ?? Infinity); - if (rank !== 0) return rank; - const name = a.name.localeCompare(b.name); - return name === 0 ? String(a.id).localeCompare(String(b.id)) : name; - }); - }); + const describeDisplayUrlForRow = (row: IntegrationRow): string | undefined => { + const runtime = runtimes.get(row.plugin_id); + const describe = runtime?.plugin.describeIntegrationDisplay; + if (!describe) return undefined; + const record = rowToIntegrationRecord(row); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read + try { + const display = describe(record); + return display.url && display.url.length > 0 ? display.url : undefined; + } catch { + return undefined; + } + }; + + const integrationsList = (): Effect.Effect => + core + .findMany("integration", {}) + .pipe( + Effect.map((rows) => [ + ...staticSources().map(staticSourceToIntegration), + ...rows.map((row) => + rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)), + ), + ]), + ); - // Same union shape as secretsList but projected to the leaner - // SecretListEntry shape that plugins get via ctx.secrets.list(). - const secretsListForCtx = () => + const integrationsGet = ( + slug: IntegrationSlug, + ): Effect.Effect => Effect.gen(function* () { - const list = yield* secretsList(); - return list.map((ref) => ({ - id: String(ref.id), - scopeId: ref.scopeId, - name: ref.name, - provider: ref.provider, - })); + const staticSource = staticSources().find((source) => source.id === String(slug)); + if (staticSource) return staticSourceToIntegration(staticSource); + const row = yield* findIntegrationRow(slug); + return row + ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)) + : null; }); - // ------------------------------------------------------------------ - // Connections facade — sign-in state as a first-class primitive. - // Connection rows own one or more backing `secret` rows via - // `secret.owned_by_connection_id`; the SDK orchestrates refresh via - // the registered provider keyed by `connection.provider`. - // ------------------------------------------------------------------ - - // Refresh skew: treat the access token as "about to expire" when - // we're within this many ms of the expiry the AS declared. - // Matches the value the old per-plugin refresh code used, so - // behavior under the new SDK orchestration stays identical. - const CONNECTION_REFRESH_SKEW_MS = 60_000; - - const rowToConnection = (row: ConnectionRow): ConnectionRef => - ConnectionRef.make({ - id: ConnectionId.make(row.id), - scopeId: ScopeId.make(row.scope_id), - provider: row.provider, - identityLabel: row.identity_label ?? null, - accessTokenSecretId: SecretId.make(row.access_token_secret_id), - refreshTokenSecretId: - row.refresh_token_secret_id != null ? SecretId.make(row.refresh_token_secret_id) : null, - expiresAt: row.expires_at != null ? Number(row.expires_at) : null, - oauthScope: row.scope ?? null, - providerState: Option.getOrNull(decodeProviderState(decodeJsonColumn(row.provider_state))), - identityOverride: Option.getOrNull( - decodeConnectionIdentityOverride(decodeJsonColumn(row.identity_override)), + const integrationsGetRecord = ( + slug: IntegrationSlug, + ): Effect.Effect => + findIntegrationRow(slug).pipe( + Effect.map((row) => + row ? rowToIntegrationRecord(row, describeAuthMethodsForRow(row)) : null, ), - createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at), - updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at), - }); + ); - const findInnermostConnectionRow = ( - id: string, - ): Effect.Effect => + const integrationsRegister = ( + pluginId: string, + input: RegisterIntegrationInput, + ): Effect.Effect => + transaction( + Effect.gen(function* () { + const now = new Date(); + const existing = yield* findIntegrationRow(input.slug); + const config = input.config === undefined ? null : input.config; + if (existing) { + yield* core.updateMany("integration", { + where: (b: AnyCb) => b("slug", "=", String(input.slug)), + set: { + plugin_id: pluginId, + description: input.description, + config, + can_remove: input.canRemove ?? Boolean(existing.can_remove), + can_refresh: input.canRefresh ?? Boolean(existing.can_refresh), + updated_at: now, + }, + }); + return; + } + yield* core.create("integration", { + tenant, + slug: String(input.slug), + plugin_id: pluginId, + description: input.description, + config, + can_remove: input.canRemove ?? true, + can_refresh: input.canRefresh ?? false, + created_at: now, + updated_at: now, + }); + }), + ); + + const integrationsUpdate = ( + slug: IntegrationSlug, + patch: { + readonly description?: string; + readonly config?: IntegrationConfig; + }, + ): Effect.Effect => Effect.gen(function* () { - const rows = yield* core.findMany("connection", { - where: scopedWhere(scopeIds, byId(id)), + const set: Record = { updated_at: new Date() }; + if (patch.description !== undefined) set.description = patch.description; + if (patch.config !== undefined) set.config = patch.config; + yield* core.updateMany("integration", { + where: (b: AnyCb) => b("slug", "=", String(slug)), + set, }); - return findInnermost(rows as readonly ConnectionRow[]); }); - const connectionsGet = (id: string): Effect.Effect => + const integrationsUpdatePublic = ( + slug: IntegrationSlug, + patch: { readonly description?: string }, + ): Effect.Effect => Effect.gen(function* () { - const row = yield* findInnermostConnectionRow(id); - return row ? rowToConnection(row) : null; + const existing = yield* findIntegrationRow(slug); + if (!existing) return yield* new IntegrationNotFoundError({ slug }); + yield* integrationsUpdate(slug, patch); }); - const connectionsGetAtScope = ( - id: string, - scope: string, - ): Effect.Effect => - Effect.gen(function* () { - yield* assertScopeInStack("connection get scope", scope); - const row = yield* findConnectionRowAtScope({ - connectionId: id, - scopeId: scope, - }); - return row ? rowToConnection(row) : null; - }); + const integrationsRemove = ( + slug: IntegrationSlug, + ): Effect.Effect => + transaction( + Effect.gen(function* () { + const existing = yield* findIntegrationRow(slug); + if (!existing) return; + if (!existing.can_remove) { + return yield* new IntegrationRemovalNotAllowedError({ slug }); + } + // Drop owned connections / tools / definitions for this integration. + const where = (b: AnyCb) => b("integration", "=", String(slug)); + yield* core.deleteMany("tool", { where }); + yield* core.deleteMany("definition", { where }); + yield* core.deleteMany("connection", { where }); + yield* core.deleteMany("integration", { + where: (b: AnyCb) => b("slug", "=", String(slug)), + }); + }), + ); - const connectionsList = (): Effect.Effect => + const integrationsDetect = ( + url: string, + ): Effect.Effect => Effect.gen(function* () { - const rows = yield* core.findMany("connection", { where: scopedWhere(scopeIds) }); - // Dedup by id, innermost scope wins — same rule as sources/tools. - const byId = new Map(); - const byIdRank = new Map(); - for (const row of rows as readonly ConnectionRow[]) { - const rank = scopeRank(row); - const existing = byIdRank.get(row.id); - if (existing === undefined || rank < existing) { - byId.set(row.id, row); - byIdRank.set(row.id, rank); - } + const results: IntegrationDetectionResult[] = []; + for (const runtime of runtimes.values()) { + if (!runtime.plugin.detect) continue; + const result = yield* runtime.plugin + .detect({ ctx: runtime.ctx, url }) + .pipe( + Effect.mapError((cause) => pluginStorageFailure(runtime.plugin.id, "detect", cause)), + ); + if (result) results.push(result); } - return [...byId.values()].map(rowToConnection); + return results; }); - // Write a secret value through a specific provider, bypassing the - // bare-secrets ownership check so the SDK can stamp - // `owned_by_connection_id` atomically alongside a connection row. - const writeOwnedSecret = (params: { - id: string; - scope: string; - name: string; - value: string; - provider: string; - ownedByConnectionId: string; - }): Effect.Effect => - Effect.gen(function* () { - const target = secretProviders.get(params.provider); - if (!target) { - return yield* new StorageError({ - message: `Unknown secret provider: ${params.provider}`, - cause: undefined, - }); + // ------------------------------------------------------------------ + // Per-connection tool production + // ------------------------------------------------------------------ + + const produceConnectionTools = ( + integrationRow: IntegrationRow, + ref: ConnectionRef, + ): Effect.Effect => + Effect.gen(function* () { + const runtime = runtimes.get(integrationRow.plugin_id); + const keys = yield* Effect.try({ + try: () => ownedKeys(ref.owner), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const owner = ref.owner; + const where = (b: AnyCb) => + b.and( + byOwner(owner)(b), + b("integration", "=", String(ref.integration)), + b("connection", "=", String(ref.name)), + ); + + // Defense in depth (and cleanup for rows created before the create-time + // guard, or emptied by an external edit): a credentialed non-OAuth + // connection with no bound credential inputs can never resolve a value, + // so never advertise tools for it — every call would fail with + // `connection_value_missing`. OAuth connections resolve via refresh and + // carry their token outside `item_ids`; no-auth (`"none"` template) + // connections legitimately bind nothing (an empty `item_ids` is their + // canonical shape) — both are exempt. + const existingRow = yield* findConnectionRow(ref); + if ( + existingRow && + existingRow.oauth_client == null && + existingRow.template !== String(NO_AUTH_TEMPLATE) && + Object.keys(connectionItemIds(existingRow)).length === 0 + ) { + yield* transaction( + Effect.gen(function* () { + yield* core.deleteMany("tool", { where }); + yield* core.deleteMany("definition", { where }); + }), + ); + return []; } - if (!target.writable || !target.set) { - return yield* new StorageError({ - message: `Secret provider "${target.key}" is read-only`, - cause: undefined, - }); + + if (!runtime?.plugin.resolveTools) { + // No dynamic tools — clear any existing rows and return empty. + yield* transaction( + Effect.gen(function* () { + yield* core.deleteMany("tool", { where }); + yield* core.deleteMany("definition", { where }); + }), + ); + return []; } - yield* target.set(params.id, params.value, params.scope); + + const result: ResolveToolsResult = yield* runtime.plugin + .resolveTools({ + integration: rowToIntegration(integrationRow), + config: decodeJsonColumn(integrationRow.config), + connection: ref, + template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null, + getValue: () => resolveConnectionValueByRef(ref), + }) + .pipe( + Effect.mapError((cause) => + pluginStorageFailure(integrationRow.plugin_id, "resolveTools", cause), + ), + ); const now = new Date(); - yield* core.deleteMany("secret", { - where: byScopedId(params.scope, params.id), - }); - yield* core.create("secret", { - id: params.id, - scope_id: params.scope, - name: params.name, - provider: target.key, - owned_by_connection_id: params.ownedByConnectionId, + const toolRows = result.tools.map((tool: ToolDef) => ({ + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(ref.integration), + connection: String(ref.name), + plugin_id: integrationRow.plugin_id, + name: String(tool.name), + description: tool.description ?? "", + input_schema: tool.inputSchema ?? null, + output_schema: tool.outputSchema ?? null, + annotations: tool.annotations ?? null, created_at: now, - }); - }); + updated_at: now, + })); - const pickWritableProvider = ( - requested?: string, - ): Effect.Effect => - Effect.gen(function* () { - if (requested) { - const p = secretProviders.get(requested); - if (!p) { - return yield* new StorageError({ - message: `Unknown secret provider: ${requested}`, - cause: undefined, - }); - } - return p; - } - for (const p of secretProviders.values()) { - if (p.writable && p.set) return p; - } - return yield* new StorageError({ - message: "No writable secret providers registered", - cause: undefined, - }); + const definitionRows = Object.entries(result.definitions ?? {}).map(([name, schema]) => ({ + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(ref.integration), + connection: String(ref.name), + plugin_id: integrationRow.plugin_id, + name, + schema, + created_at: now, + })); + + yield* transaction( + Effect.gen(function* () { + yield* core.deleteMany("tool", { where }); + yield* core.deleteMany("definition", { where }); + yield* core.createMany("tool", toolRows); + yield* core.createMany("definition", definitionRows); + }), + ); + + return result.tools.map((tool: ToolDef) => + rowToTool( + { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(ref.integration), + connection: String(ref.name), + plugin_id: integrationRow.plugin_id, + name: String(tool.name), + description: tool.description ?? "", + input_schema: tool.inputSchema ?? null, + output_schema: tool.outputSchema ?? null, + annotations: tool.annotations ?? null, + created_at: now, + updated_at: now, + } as ConnectionToolRow, + tool.annotations, + ), + ); }); + // ------------------------------------------------------------------ + // Connections + // ------------------------------------------------------------------ + const connectionsCreate = ( input: CreateConnectionInput, - ): Effect.Effect => + ): Effect.Effect< + Connection, + | IntegrationNotFoundError + | CredentialProviderNotRegisteredError + | InvalidConnectionInputError + | StorageFailure + > => Effect.gen(function* () { - if (!scopeIds.some((scopeId) => scopeId === input.scope)) { - return yield* new StorageError({ + const name = connectionIdentifier(String(input.name)); + // Typed (not StorageError) so the HTTP edge can answer 400 with the + // reason instead of an opaque 500 — callers can act on it. + if (input.owner === "user" && subject == null) { + return yield* new InvalidConnectionInputError({ message: - `connections.create targets scope "${input.scope}" which is not ` + - `in the executor's scope stack [${scopeIds.join(", ")}].`, - cause: undefined, + 'Cannot create a personal connection: this context has no user subject. Create it with owner "org", or connect as a signed-in user.', }); } - if (!resolveConnectionProvider(input.provider)) { - return yield* new ConnectionProviderNotRegisteredError({ - provider: input.provider, - connectionId: input.id, + const integrationRow = yield* findIntegrationRow(input.integration); + if (!integrationRow) { + return yield* new IntegrationNotFoundError({ + slug: input.integration, }); } - const writable = yield* pickWritableProvider(); - const now = new Date(); - - return yield* transaction( - Effect.gen(function* () { - // Drop any existing connection row at this scope first so a - // re-auth replaces cleanly. Owned-secret rows for the old - // connection are removed by the cascade below (we delete - // both old + new token secret ids explicitly). - yield* core.deleteMany("connection", { - where: byScopedId(input.scope, input.id), + // Resolve the value origin(s) → one provider + an item_ids map (one entry + // per named input). All of a connection's inputs share a single provider: + // pasted inputs go to the default writable store, external `from` inputs to + // their provider. Mixing pasted + external, or two external providers, is + // rejected (the connection row carries one `provider`). + const inputs = normalizeConnectionInputs(input); + const pasted = inputs.filter((i) => "value" in i.origin); + const external = inputs.filter((i) => "from" in i.origin); + // A credentialed connection is born wired: it must reference at least + // one credential input. An empty binding (no inputs at all — e.g. an + // empty `values`/`inputs` map) is a credential with no credential: it + // would persist, produce a full tool catalog, and then fail every + // invocation with `connection_value_missing`. Reject it here — EXCEPT + // for the no-auth template ("none"), where zero inputs and an empty + // `item_ids` map are the canonical shape (public MCP servers; the UI + // submits `values: {}` for them). OAuth connections are minted via + // `mintOAuthConnection`, not this path; an external `from` reference + // may resolve to null and is surfaced at invoke time, not here. + const isNoAuth = String(input.template) === String(NO_AUTH_TEMPLATE); + if (inputs.length === 0 && !isNoAuth) { + return yield* new InvalidConnectionInputError({ + message: "A connection must supply at least one credential input.", + }); + } + let providerKey: string; + const itemIds: Record = {}; + if (external.length > 0 && pasted.length > 0) { + return yield* new InvalidConnectionInputError({ + message: "A connection cannot mix pasted and external-provider inputs.", + }); + } + if (external.length > 0) { + const providers = new Set( + external.map((i) => ("from" in i.origin ? String(i.origin.from.provider) : "")), + ); + if (providers.size > 1) { + return yield* new InvalidConnectionInputError({ + message: "A connection's inputs must all use the same external provider.", + }); + } + const [only] = [...providers]; + const provider = credentialProviders.get(only ?? ""); + if (!provider) { + return yield* new CredentialProviderNotRegisteredError({ + provider: ProviderKey.make(only ?? ""), }); + } + providerKey = only ?? ""; + for (const i of external) { + if ("from" in i.origin) itemIds[i.variable] = String(i.origin.from.id); + } + } else { + const provider = defaultWritableProvider(); + if (!provider) { + return yield* new CredentialProviderNotRegisteredError({ + provider: ProviderKey.make("default"), + }); + } + providerKey = String(provider.key); + for (const i of pasted) { + const itemId = `connection:${input.owner}:${input.integration}:${name}:${i.variable}`; + if ("value" in i.origin && provider.set) { + yield* provider.set(ProviderItemId.make(itemId), i.origin.value); + } + itemIds[i.variable] = itemId; + } + } - yield* writeOwnedSecret({ - id: input.accessToken.secretId, - scope: input.scope, - name: input.accessToken.name, - value: input.accessToken.value, - provider: writable.key, - ownedByConnectionId: input.id, + const keys = yield* Effect.try({ + try: () => ownedKeys(input.owner), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const now = new Date(); + yield* transaction( + Effect.gen(function* () { + const existing = yield* findConnectionRow({ + owner: input.owner, + integration: input.integration, + name, }); - if (input.refreshToken) { - yield* writeOwnedSecret({ - id: input.refreshToken.secretId, - scope: input.scope, - name: input.refreshToken.name, - value: input.refreshToken.value, - provider: writable.key, - ownedByConnectionId: input.id, + const set: Record = { + template: String(input.template), + provider: providerKey, + item_ids: itemIds, + identity_label: input.identityLabel ?? null, + updated_at: now, + }; + if (existing) { + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(input.owner)(b), + b("integration", "=", String(input.integration)), + b("name", "=", String(name)), + ), + set, + }); + } else { + yield* core.create("connection", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(input.integration), + name: String(name), + template: String(input.template), + provider: providerKey, + item_ids: itemIds, + identity_label: input.identityLabel ?? null, + oauth_client: null, + refresh_item_id: null, + expires_at: null, + oauth_scope: null, + provider_state: null, + created_at: now, + updated_at: now, }); } + }), + ); - yield* core.create("connection", { - id: input.id, - scope_id: input.scope, - provider: input.provider, + const ref: ConnectionRef = { + owner: input.owner, + integration: input.integration, + name, + }; + // Produce + persist tools for the new connection. + yield* produceConnectionTools(integrationRow, ref).pipe( + Effect.catchTag("IntegrationNotFoundError", () => Effect.succeed([] as readonly Tool[])), + ); + + const row = yield* findConnectionRow(ref); + return row + ? rowToConnection(row) + : rowToConnection({ + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(input.integration), + name: String(name), + template: String(input.template), + provider: providerKey, + item_ids: itemIds, identity_label: input.identityLabel ?? null, - access_token_secret_id: input.accessToken.secretId, - refresh_token_secret_id: input.refreshToken?.secretId ?? null, - expires_at: input.expiresAt ?? null, - scope: input.oauthScope ?? null, - provider_state: input.providerState ?? null, - identity_override: null, + oauth_client: null, + refresh_item_id: null, + expires_at: null, + oauth_scope: null, + provider_state: null, created_at: now, updated_at: now, - }); - - return ConnectionRef.make({ - id: input.id, - scopeId: input.scope, - provider: input.provider, - identityLabel: input.identityLabel, - accessTokenSecretId: input.accessToken.secretId, - refreshTokenSecretId: input.refreshToken?.secretId ?? null, - expiresAt: input.expiresAt, - oauthScope: input.oauthScope, - providerState: input.providerState, - identityOverride: null, - createdAt: now, - updatedAt: now, - }); - }), - ); + } as ConnectionRow); }); - // Write new token material into the existing secret rows and bump - // the connection row's expiry / scope / providerState. Never - // mutates `access_token_secret_id` or `refresh_token_secret_id` — - // those stay pinned so consumers that stashed them in source - // configs still resolve. - const connectionsUpdateTokensForRow = ( - input: UpdateConnectionTokensInput, - row: ConnectionRow, - ): Effect.Effect => + // Mint (or re-mint) an OAuth connection: write the connection row with its + // OAuth lifecycle fields (the access token is already stored in the provider + // by the OAuth service) + produce the connection's tools. Mirrors + // `connectionsCreate`'s upsert + tool-production, stamping the OAuth columns. + const mintOAuthConnection = ( + input: MintOAuthConnectionInput, + ): Effect.Effect => Effect.gen(function* () { - const writable = yield* pickWritableProvider(); - const accessName = `Connection ${input.id} access token`; - const refreshName = `Connection ${input.id} refresh token`; - - return yield* transaction( + const name = connectionIdentifier(String(input.name)); + yield* requireUserSubject(input.owner); + const integrationRow = yield* findIntegrationRow(input.integration); + if (!integrationRow) { + return yield* new StorageError({ + message: `Integration not found: ${input.integration}`, + cause: undefined, + }); + } + const keys = yield* Effect.try({ + try: () => ownedKeys(input.owner), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const now = new Date(); + const ref: ConnectionRef = { + owner: input.owner, + integration: input.integration, + name, + }; + yield* transaction( Effect.gen(function* () { - yield* writeOwnedSecret({ - id: row.access_token_secret_id, - scope: row.scope_id, - name: accessName, - value: input.accessToken, - provider: writable.key, - ownedByConnectionId: row.id, - }); - const rotatedRefresh = input.refreshToken ?? undefined; - if (rotatedRefresh && row.refresh_token_secret_id) { - yield* writeOwnedSecret({ - id: row.refresh_token_secret_id, - scope: row.scope_id, - name: refreshName, - value: rotatedRefresh, - provider: writable.key, - ownedByConnectionId: row.id, + const existing = yield* findConnectionRow(ref); + const set: Record = { + template: String(input.template), + provider: input.provider, + item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, + identity_label: input.identityLabel ?? null, + oauth_client: String(input.oauthClient), + oauth_client_owner: input.oauthClientOwner, + refresh_item_id: input.refreshItemId, + expires_at: input.expiresAt, + oauth_scope: input.oauthScope, + updated_at: now, + }; + if (existing) { + yield* core.updateMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(input.owner)(b), + b("integration", "=", String(input.integration)), + b("name", "=", String(name)), + ), + set, }); - } - const now = new Date(); - const patch: Record = { updated_at: now }; - if (input.expiresAt !== undefined) patch.expires_at = input.expiresAt ?? null; - if (input.oauthScope !== undefined) patch.scope = input.oauthScope ?? null; - if (input.providerState !== undefined) - patch.provider_state = input.providerState ?? null; - if (input.identityLabel !== undefined) - patch.identity_label = input.identityLabel ?? null; - yield* core.updateMany("connection", { - where: byScopedId(row.scope_id, row.id), - set: patch, - }); - const updated = yield* findConnectionRowAtScope({ - connectionId: row.id, - scopeId: row.scope_id, - }); - if (!updated) { - return yield* new ConnectionNotFoundError({ - connectionId: input.id, + } else { + yield* core.create("connection", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(input.integration), + name: String(name), + template: String(input.template), + provider: input.provider, + item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, + identity_label: input.identityLabel ?? null, + oauth_client: String(input.oauthClient), + oauth_client_owner: input.oauthClientOwner, + refresh_item_id: input.refreshItemId, + expires_at: input.expiresAt, + oauth_scope: input.oauthScope, + provider_state: null, + created_at: now, + updated_at: now, }); } - return rowToConnection(updated); }), ); - }); - const connectionsUpdateTokens = ( - input: UpdateConnectionTokensInput, - ): Effect.Effect => - Effect.gen(function* () { - const row = yield* findInnermostConnectionRow(input.id); - if (!row) { - return yield* new ConnectionNotFoundError({ connectionId: input.id }); - } - return yield* connectionsUpdateTokensForRow(input, row); + // Produce + persist tools for the minted connection (same path + // connections.create uses). + yield* produceConnectionTools(integrationRow, ref).pipe( + Effect.catchTag("IntegrationNotFoundError", () => Effect.succeed([] as readonly Tool[])), + ); + + const row = yield* findConnectionRow(ref); + return row + ? rowToConnection(row) + : rowToConnection({ + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: String(input.integration), + name: String(name), + template: String(input.template), + provider: input.provider, + item_ids: { [PRIMARY_INPUT_VARIABLE]: input.itemId }, + identity_label: input.identityLabel ?? null, + oauth_client: String(input.oauthClient), + oauth_client_owner: input.oauthClientOwner, + refresh_item_id: input.refreshItemId, + expires_at: input.expiresAt, + oauth_scope: input.oauthScope, + provider_state: null, + created_at: now, + updated_at: now, + } as ConnectionRow); }); - const connectionsSetIdentityLabel = ( - id: string, - label: string | null, + const connectionsList = (filter?: { + readonly integration?: IntegrationSlug; + readonly owner?: Owner; + }): Effect.Effect => + core + .findMany("connection", { + where: (b: AnyCb) => + b.and( + filter?.integration === undefined + ? true + : b("integration", "=", String(filter.integration)), + filter?.owner === undefined ? true : b("owner", "=", filter.owner), + ), + }) + .pipe(Effect.map((rows) => rows.map(rowToConnection))); + + const connectionsGet = (ref: ConnectionRef): Effect.Effect => + findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null))); + + const connectionsRemove = ( + ref: ConnectionRef, ): Effect.Effect => + transaction( + Effect.gen(function* () { + const row = yield* findConnectionRow(ref); + if (!row) { + return yield* new ConnectionNotFoundError({ + owner: ref.owner, + integration: ref.integration, + name: ref.name, + }); + } + const integrationRow = yield* findIntegrationRow(ref.integration); + const runtime = integrationRow ? runtimes.get(integrationRow.plugin_id) : undefined; + if (integrationRow && runtime?.plugin.removeConnection) { + yield* runtime.plugin + .removeConnection({ + ctx: runtime.ctx, + integration: ref.integration, + connection: ref, + }) + .pipe( + Effect.mapError((cause) => + pluginStorageFailure(integrationRow.plugin_id, "removeConnection", cause), + ), + ); + } + const where = (b: AnyCb) => + b.and( + byOwner(ref.owner)(b), + b("integration", "=", String(ref.integration)), + b("connection", "=", String(ref.name)), + ); + yield* core.deleteMany("tool", { where }); + yield* core.deleteMany("definition", { where }); + yield* core.deleteMany("connection", { + where: (b: AnyCb) => + b.and( + byOwner(ref.owner)(b), + b("integration", "=", String(ref.integration)), + b("name", "=", String(ref.name)), + ), + }); + }), + ); + + const connectionsRefresh = ( + ref: ConnectionRef, + ): Effect.Effect< + readonly Tool[], + ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure + > => Effect.gen(function* () { - const row = yield* findInnermostConnectionRow(id); + const row = yield* findConnectionRow(ref); if (!row) { return yield* new ConnectionNotFoundError({ - connectionId: ConnectionId.make(id), + owner: ref.owner, + integration: ref.integration, + name: ref.name, }); } - yield* core.updateMany("connection", { - where: byScopedId(row.scope_id, id), - set: { - identity_label: label ?? null, - updated_at: new Date(), - }, - }); + const integrationRow = yield* findIntegrationRow(ref.integration); + if (!integrationRow) { + return yield* new IntegrationNotFoundError({ slug: ref.integration }); + } + return yield* produceConnectionTools(integrationRow, ref); }); - const connectionsSetIdentityOverride = ( - input: UpdateConnectionIdentityInput, - ): Effect.Effect => + // ------------------------------------------------------------------ + // Tools (read surface) + // ------------------------------------------------------------------ + + const matchesToolFilter = (tool: Tool, filter: ToolListFilter | undefined): boolean => { + if (!filter) return true; + if (filter.integration !== undefined && tool.integration !== filter.integration) return false; + if (filter.owner !== undefined && tool.owner !== filter.owner) return false; + if (filter.connection !== undefined && tool.connection !== filter.connection) return false; + if (filter.query !== undefined) { + const q = filter.query.toLowerCase(); + const hay = `${tool.name} ${tool.description}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }; + + const toolsList = (filter?: ToolListFilter): Effect.Effect => Effect.gen(function* () { - yield* assertScopeInStack("connection identity targetScope", input.targetScope); - const row = yield* findConnectionRowAtScope({ - connectionId: input.id, - scopeId: input.targetScope, + const rows = yield* core.findMany("tool", { + where: (b: AnyCb) => + b.and( + filter?.integration === undefined + ? true + : b("integration", "=", String(filter.integration)), + filter?.owner === undefined ? true : b("owner", "=", filter.owner), + filter?.connection === undefined + ? true + : b("connection", "=", String(filter.connection)), + ), }); - if (!row) { - return yield* new ConnectionNotFoundError({ - connectionId: input.id, - }); + const includeBlocked = filter?.includeBlocked ?? false; + const policyRows = yield* core.findMany("tool_policy", {}); + const tools: Tool[] = []; + for (const row of rows) { + const tool = rowToTool(row); + if (!matchesToolFilter(tool, filter)) continue; + if (!includeBlocked) { + const effective = resolveEffectivePolicy( + normalizedPolicyId(tool), + policyRows, + ownerRankForRow, + tool.annotations?.requiresApproval, + ); + if (effective.action === "block") continue; + } + tools.push(tool); } - yield* core.updateMany("connection", { - where: byScopedId(input.targetScope, input.id), - set: { - identity_override: input.identityOverride, - updated_at: new Date(), - }, - }); - const updated = yield* findConnectionRowAtScope({ - connectionId: input.id, - scopeId: input.targetScope, - }); - if (!updated) { - return yield* new ConnectionNotFoundError({ - connectionId: input.id, - }); + for (const entry of staticTools.values()) { + const tool = staticToolToTool(entry); + if (!matchesToolFilter(tool, filter)) continue; + if (!includeBlocked) { + const effective = resolveEffectivePolicy( + normalizedPolicyId(tool), + policyRows, + ownerRankForRow, + tool.annotations?.requiresApproval, + ); + if (effective.action === "block") continue; + } + tools.push(tool); } - return rowToConnection(updated); + return tools; }); - const connectionsRemove = ( - input: RemoveConnectionInput, - ): Effect.Effect => + const toolSchema = ( + address: ToolAddress, + ): Effect.Effect => Effect.gen(function* () { - const id = input.id; - const targetScope = input.targetScope; - yield* assertScopeInStack("connection remove targetScope", targetScope); - const allRows = yield* core.findMany("connection", { - where: scopedWhere(scopeIds, byId(id)), - }); - const row = - (allRows as readonly ConnectionRow[]).find( - (candidate) => candidate.scope_id === targetScope, - ) ?? null; - if (!row) return; - const usages = (yield* connectionsUsagesStrict(id)).filter( - (usage) => usage.scopeId === targetScope, - ); - if (usages.length > 0) { - return yield* new ConnectionInUseError({ - connectionId: ConnectionId.make(id), - usageCount: usages.length, + const staticEntry = staticTools.get(String(address)); + if (staticEntry) { + const tool = staticToolToTool(staticEntry); + const preview = yield* Effect.tryPromise({ + try: () => + buildToolTypeScriptPreview({ + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + defs: new Map(), + }), + catch: (cause) => + storageFailureFromUnknown("Failed to build static tool TypeScript preview", cause), + }).pipe(Effect.option); + return ToolSchemaView.make({ + address, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + inputTypeScript: Option.getOrUndefined(preview)?.inputTypeScript, + outputTypeScript: Option.getOrUndefined(preview)?.outputTypeScript, + typeScriptDefinitions: Option.getOrUndefined(preview)?.typeScriptDefinitions, }); } - const scope = targetScope; - yield* transaction( - Effect.gen(function* () { - // Find every owned secret at this scope and drop through - // its provider + the core row. We look up by - // `owned_by_connection_id` rather than just the two ids on - // the connection row so any accidentally-orphaned siblings - // get cleaned up too. - const owned = yield* core.findMany("secret", { - where: (b) => b.and(b("owned_by_connection_id", "=", id), b("scope_id", "=", scope)), - }); - const deleters = [...secretProviders.values()].filter( - (p): p is typeof p & { delete: NonNullable } => - !!(p.writable && p.delete), - ); - for (const secret of owned) { - yield* Effect.all( - deleters.map((p) => - p - .delete(secret.id, scope) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning( - `Failed to delete connection-owned secret from provider ${p.key}`, - cause, - ).pipe(Effect.as(false)), - ), - ), - ), - { concurrency: "unbounded" }, - ); - } - yield* core.deleteMany("secret", { - where: (b) => b.and(b("owned_by_connection_id", "=", id), b("scope_id", "=", scope)), - }); - yield* core.deleteMany("connection", { - where: byScopedId(scope, id), - }); - }), - ); - }); - - // Typed error union that `connectionsAccessToken` and every helper - // that participates in a refresh returns. Pulled out into a type - // alias because it has to match the Deferred's channel exactly — - // otherwise concurrent waiters and the leader diverge on the error - // type. - type AccessTokenError = - | ConnectionNotFoundError - | ConnectionProviderNotRegisteredError - | ConnectionRefreshNotSupportedError - | ConnectionReauthRequiredError - | ConnectionRefreshError - | StorageFailure; - - // The actual work of a single refresh cycle, factored out so the - // concurrency gate (`connectionsAccessToken`) stays readable. Runs - // for the fiber that wins the `refreshInFlight` race. - const performRefresh = (ref: ConnectionRef): Effect.Effect => - Effect.gen(function* () { - const provider = resolveConnectionProvider(ref.provider); - if (!provider) { - return yield* new ConnectionProviderNotRegisteredError({ - provider: ref.provider, - connectionId: ref.id, - }); - } - if (!provider.refresh) { - return yield* new ConnectionRefreshNotSupportedError({ - connectionId: ref.id, - provider: ref.provider, - }); - } - - const refreshTokenValue = ref.refreshTokenSecretId - ? yield* connectionSecretGetAtScope(ref.refreshTokenSecretId, ref.scopeId) - : null; - - // RFC 6749 §5.2 `invalid_grant` (and anything else the - // provider tags with `reauthRequired`) is terminal — the - // stored refresh token can't recover. Translate into the - // caller-visible "re-authenticate" error so the UI can - // prompt sign-in instead of silently retrying. - const rawResult: Result.Result = - yield* Effect.result( - provider.refresh({ - connectionId: ref.id, - scopeId: ref.scopeId, - identityLabel: ref.identityLabel, - refreshToken: refreshTokenValue, - providerState: ref.providerState, - oauthScope: ref.oauthScope, - }), - ); - if (Result.isFailure(rawResult)) { - const err = rawResult.failure; - if (err.reauthRequired) { - return yield* new ConnectionReauthRequiredError({ - connectionId: err.connectionId, - provider: ref.provider, - // oxlint-disable-next-line executor/no-unknown-error-message -- typed: ConnectionRefreshError.message is provider-facing domain data, not an unknown caught error - message: err["message"], - }); - } - return yield* err; - } - const result = rawResult.success; - const row = yield* findConnectionRowAtScope({ - connectionId: ref.id, - scopeId: ref.scopeId, + const parsed = parseToolAddress(String(address)); + if (!parsed) return null; + const row = yield* core.findFirst("tool", { + where: (b: AnyCb) => + b.and( + byOwner(parsed.owner)(b), + b("integration", "=", String(parsed.integration)), + b("connection", "=", String(parsed.connection)), + b("name", "=", String(parsed.tool)), + ), }); - if (!row) { - return yield* new ConnectionNotFoundError({ - connectionId: ref.id, - }); - } - yield* connectionsUpdateTokensForRow( - { - id: ref.id, - accessToken: result.accessToken, - refreshToken: result.refreshToken, - expiresAt: result.expiresAt, - oauthScope: result.oauthScope, - providerState: result.providerState, - } as UpdateConnectionTokensInput, - row, - ); - - return result.accessToken; - }); - - // accessToken(id) — the single surface plugins use at invoke time. - // Resolves the backing secret, checks expiry, calls the provider's - // refresh handler if we're inside the skew window. New tokens are - // written back through the same provider and the connection row is - // patched with the new expiry. - // - // Concurrent invokes on an expired token all share one refresh. - // The fiber that wins the `refreshInFlightLock` race registers a - // Deferred and performs the refresh; every other concurrent caller - // observes the Deferred and awaits its completion. The Deferred is - // pulled out of the map before the refresh result resolves so - // later invokes don't reuse a completed slot. - const connectionsAccessTokenForRow = ( - row: ConnectionRow, - ): Effect.Effect => - Effect.gen(function* () { - const ref = rowToConnection(row); - const now = Date.now(); - const needsRefresh = - ref.expiresAt !== null && ref.expiresAt - CONNECTION_REFRESH_SKEW_MS <= now; - - if (!needsRefresh) { - const current = yield* connectionSecretGetAtScope(ref.accessTokenSecretId, ref.scopeId); - if (current !== null) return current; - // Fall through to refresh if the stored token vanished — a - // genuinely-missing secret with no way to refresh is a - // hard-failure, same behavior as if `expires_at` had passed. - } - - // Concurrency gate. `action` either returns the fresh access - // token (this fiber did the refresh) or the already-running - // Deferred that another fiber stamped into the map (this fiber - // piggybacks on their refresh). - const refreshKey = `${ref.scopeId}\u0000${ref.id}`; - const action = yield* refreshInFlightLock.withPermits(1)( - Effect.gen(function* () { - const existing = refreshInFlight.get(refreshKey); - if (existing) { - return { - kind: "await" as const, - deferred: existing, - }; - } - const deferred = yield* Deferred.make(); - refreshInFlight.set(refreshKey, deferred); - return { kind: "lead" as const, deferred }; - }), - ); - - if (action.kind === "await") { - return yield* Deferred.await(action.deferred); - } + if (!row) return null; + const tool = rowToTool(row); - // Leader path: run the refresh, pipe the outcome into the - // Deferred (so waiters wake up), and then clear the map slot - // regardless of success or failure. Completing before delete - // ensures a caller that arrives during cleanup can still observe - // the settled leader result instead of starting a second refresh. - return yield* performRefresh(ref).pipe( - Effect.onExit((exit) => - refreshInFlightLock.withPermits(1)( - Effect.gen(function* () { - yield* Deferred.done(action.deferred, exit); - refreshInFlight.delete(refreshKey); - }), + const definitionRows = yield* core.findMany("definition", { + where: (b: AnyCb) => + b.and( + byOwner(parsed.owner)(b), + b("integration", "=", String(parsed.integration)), + b("connection", "=", String(parsed.connection)), ), - ), - ); - }); - - const connectionsAccessToken = (id: string): Effect.Effect => - Effect.gen(function* () { - const row = yield* findInnermostConnectionRow(id); - if (!row) { - return yield* new ConnectionNotFoundError({ - connectionId: ConnectionId.make(id), - }); - } - return yield* connectionsAccessTokenForRow(row); - }); - - const connectionsAccessTokenAtScope = ( - id: string, - scope: string, - ): Effect.Effect => - Effect.gen(function* () { - yield* assertScopeInStack("connection accessToken scope", scope); - const row = yield* findConnectionRowAtScope({ - connectionId: id, - scopeId: scope, }); - if (!row) { - return yield* new ConnectionNotFoundError({ - connectionId: ConnectionId.make(id), - }); - } - return yield* connectionsAccessTokenForRow(row); - }); - - const connectionsListForCtx = () => connectionsList(); + const defs = new Map(); + for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema)); - const scopeListLabel = () => `[${scopeIds.join(", ")}]`; - - const assertScopeInStack = ( - label: string, - scopeId: string, - ): Effect.Effect => - scopeIds.includes(scopeId) - ? Effect.void - : Effect.fail( - new StorageError({ - message: `${label} "${scopeId}" is not in the executor's scope stack ${scopeListLabel()}.`, - cause: undefined, + const referenced = collectReferencedDefinitions( + [tool.inputSchema, tool.outputSchema], + defs, + ); + const preview = yield* Effect.tryPromise({ + try: () => + buildToolTypeScriptPreview({ + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + defs, }), - ); + catch: (cause) => + storageFailureFromUnknown("Failed to build tool TypeScript preview", cause), + }).pipe(Effect.option); - const findSourceRowAtScope = (input: { - readonly pluginId: string; - readonly sourceId: string; - readonly sourceScope: string; - }): Effect.Effect => - Effect.gen(function* () { - if (!scopeIds.includes(input.sourceScope)) return null; - return yield* core.findFirst("source", { - where: (b) => - b.and( - b("plugin_id", "=", input.pluginId), - b("id", "=", input.sourceId), - b("scope_id", "=", input.sourceScope), - ), + const view = preview; + return ToolSchemaView.make({ + address, + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + schemaDefinitions: + Object.keys(referenced).length > 0 + ? (referenced as Record) + : undefined, + inputTypeScript: Option.getOrUndefined(view)?.inputTypeScript, + outputTypeScript: Option.getOrUndefined(view)?.outputTypeScript, + typeScriptDefinitions: Option.getOrUndefined(view)?.typeScriptDefinitions, }); }); - const findSourceOwnerRowAtScope = (input: { - readonly sourceId: string; - readonly sourceScope: string; - }): Effect.Effect => - Effect.gen(function* () { - if (!scopeIds.includes(input.sourceScope)) return null; - return yield* core.findFirst("source", { - where: byScopedId(input.sourceScope, input.sourceId), - }); - }); + // ------------------------------------------------------------------ + // Providers + // ------------------------------------------------------------------ - const findSecretRowAtScope = (input: { - readonly secretId: string; - readonly scopeId: string; - }): Effect.Effect => - Effect.gen(function* () { - if (!scopeIds.includes(input.scopeId)) return null; - return yield* core.findFirst("secret", { - where: byScopedId(input.scopeId, input.secretId), - }); - }); + const providersList = (): Effect.Effect => + Effect.sync(() => credentialProviderOrder.map((key) => ProviderKey.make(key))); - const findConnectionRowAtScope = (input: { - readonly connectionId: string; - readonly scopeId: string; - }): Effect.Effect => + const providersItems = ( + key: ProviderKey, + ): Effect.Effect => Effect.gen(function* () { - if (!scopeIds.includes(input.scopeId)) return null; - return yield* core.findFirst("connection", { - where: byScopedId(input.scopeId, input.connectionId), - }); + const provider = credentialProviders.get(String(key)); + if (!provider || !provider.list) return []; + return yield* provider.list(); }); - const credentialBindingRowsForSource = ( - input: CredentialBindingSourceInput, - ): Effect.Effect => - scopeIds.includes(input.sourceScope) - ? (core - .findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => - b.and( - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - ), - ), - }) - .pipe( - Effect.map((rows) => { - const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; - return (rows as readonly CredentialBindingRow[]).filter( - (row) => scopeRank(row) <= sourceSourceRank, - ); - }), - ) as Effect.Effect) - : Effect.succeed([]); - - const credentialBindingRowsForSlot = ( - input: CredentialBindingSlotInput, - ): Effect.Effect => - scopeIds.includes(input.sourceScope) - ? (core - .findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => - b.and( - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - b("slot_key", "=", input.slotKey), - ), - ), - }) - .pipe( - Effect.map((rows) => { - const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; - return (rows as readonly CredentialBindingRow[]).filter( - (row) => scopeRank(row) <= sourceSourceRank, - ); - }), - ) as Effect.Effect) - : Effect.succeed([]); - - const assertCredentialBindingTargetNotOuter = (input: { - readonly label: string; - readonly targetScope: string; - readonly sourceScope: string; - readonly sourceId: string; - }): Effect.Effect => - Effect.gen(function* () { - const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; - const targetRank = scopePrecedence.get(input.targetScope) ?? Infinity; - if (targetRank > sourceSourceRank) { - return yield* new StorageError({ - message: - `${input.label} for source "${input.sourceId}" cannot target outer scope ` + - `"${input.targetScope}" because the source lives at scope "${input.sourceScope}".`, - cause: undefined, - }); - } - }); + // ------------------------------------------------------------------ + // Policies — owner-ranked (user=0 inner, org=1 outer). + // ------------------------------------------------------------------ - const credentialBindingListForSource = (input: CredentialBindingSourceInput) => - Effect.gen(function* () { - const rows = yield* credentialBindingRowsForSource(input); - return rows - .slice() - .sort((a, b) => { - const slot = a.slot_key.localeCompare(b.slot_key); - return slot === 0 ? scopeRank(a) - scopeRank(b) : slot; - }) - .map(credentialBindingRowToRef); - }); + const ownerRankForRow = (row: { readonly owner: string }): number => + row.owner === "user" ? 0 : 1; + + // Tool policies gate by tool identity (`.`), independent of + // which connection serves it; the org/user split is handled by owner-scoped + // policy rows + ownerRank, not the match pattern. + const normalizedPolicyId = (tool: Tool): string => + tool.static + ? String(tool.address) + : `${tool.integration}.${tool.owner}.${tool.connection}.${tool.name}`; + + const policiesList = (): Effect.Effect => + core + .findMany("tool_policy", {}) + .pipe( + Effect.map((rows) => + [...rows] + .sort((a, b) => ownerRankForRow(a) - ownerRankForRow(b) || comparePolicyRow(a, b)) + .map(rowToToolPolicy), + ), + ); - const credentialBindingSet = (input: SetPluginCredentialBindingInput) => + const policiesCreate = ( + input: CreateToolPolicyInput, + ): Effect.Effect => Effect.gen(function* () { - yield* assertScopeInStack("credential binding targetScope", input.targetScope); - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - yield* assertCredentialBindingTargetNotOuter({ - label: "credential binding", - targetScope: input.targetScope, - sourceScope: input.sourceScope, - sourceId: input.sourceId, - }); - - const source = yield* findSourceRowAtScope({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - }); - if (!source) { + if (!isValidPattern(input.pattern)) { return yield* new StorageError({ - message: - `Cannot set credential binding for source "${input.sourceId}" ` + - `at scope "${input.sourceScope}": source is not visible.`, + message: `Invalid tool policy pattern: ${input.pattern}`, cause: undefined, }); } - - if (input.value.kind === "secret") { - const secretId = input.value.secretId; - const secretScope = input.value.secretScopeId ?? input.targetScope; - yield* assertScopeInStack("credential binding secretScope", secretScope); - if (scopePrecedence.get(secretScope)! < scopePrecedence.get(input.targetScope)!) { - return yield* new StorageError({ - message: - `Cannot bind secret "${secretId}" from scope "${secretScope}" ` + - `to target scope "${input.targetScope}": shared bindings cannot reference inner-scope secrets.`, - cause: undefined, - }); - } - const secret = yield* findSecretRowAtScope({ - secretId, - scopeId: secretScope, - }); - if (!secret) { - // No core routing row at this scope yet. Read-only providers - // (1password, env, …) own items that never get a row via - // `secrets.set()`, so a config-sync referencing one of those - // ids by value otherwise fails here. Walk providers that can - // enumerate, and if any owns the id, materialize a routing row - // pointing at that provider so resolution finds it. - let materialized = false; - for (const [key, provider] of secretProviders) { - let name: string | undefined; - if (provider.list) { - const entries = yield* provider - .list() - .pipe(Effect.catch(() => Effect.succeed([] as const))); - const found = entries.find((e) => e.id === secretId); - if (found) name = found.name; - } - if (name === undefined) { - // Provider didn't enumerate the id (slow list(), failed list, - // or no list() at all). Probe with get() — cheap for most - // backends — and use the id as the display name. - const value = yield* provider - .get(secretId, secretScope) - .pipe(Effect.catch(() => Effect.succeed(null as string | null))); - if (value !== null) name = secretId; - } - if (name === undefined) continue; - const now = new Date(); - yield* core.create("secret", { - id: secretId, - scope_id: secretScope, - name, - provider: key, - owned_by_connection_id: null, - created_at: now, - }); - materialized = true; - break; - } - if (!materialized) { - const providerKeys = [...secretProviders.keys()]; - return yield* new StorageError({ - message: - `Cannot bind secret "${secretId}" at scope "${secretScope}": ` + - `no registered secret provider has an item with this id ` + - `(checked: ${providerKeys.join(", ") || "none"}). ` + - `If this id points to a 1Password item, the item may have been deleted, ` + - `renamed, or live in a different vault than the one configured for this scope.`, - cause: undefined, - }); - } - } - } - - if (input.value.kind === "connection") { - const connection = yield* findConnectionRowAtScope({ - connectionId: input.value.connectionId, - scopeId: input.targetScope, + if (!isToolPolicyAction(input.action)) { + return yield* new StorageError({ + message: `Invalid tool policy action: ${String(input.action)}`, + cause: undefined, }); - if (!connection) { - return yield* new StorageError({ - message: - `Cannot bind connection "${input.value.connectionId}" at scope "${input.targetScope}": ` + - `the connection must be owned by the same scope as the binding.`, - cause: undefined, - }); - } } - - const id = credentialBindingId(input); + yield* requireUserSubject(input.owner); + const keys = yield* Effect.try({ + try: () => ownedKeys(input.owner), + catch: (cause) => storageFailureFromUnknown("invalid owner", cause), + }); + const existing = yield* core.findMany("tool_policy", { + where: byOwner(input.owner), + }); + const minPosition = existing + .map((row) => row.position) + .sort() + .at(0); + const position = input.position ?? generateKeyBetween(null, minPosition ?? null); + const id = PolicyId.make( + `pol_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`, + ); const now = new Date(); - yield* core.deleteMany("credential_binding", { - where: (b) => - b.and( - b("scope_id", "=", input.targetScope), - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - b("slot_key", "=", input.slotKey), - ), - }); - yield* core.create("credential_binding", { - id, - scope_id: input.targetScope, - plugin_id: input.pluginId, - source_id: input.sourceId, - source_scope_id: input.sourceScope, - slot_key: input.slotKey, - kind: input.value.kind, - text_value: input.value.kind === "text" ? input.value.text : null, - secret_id: input.value.kind === "secret" ? input.value.secretId : null, - secret_scope_id: - input.value.kind === "secret" ? (input.value.secretScopeId ?? input.targetScope) : null, - connection_id: input.value.kind === "connection" ? input.value.connectionId : null, + const created = yield* core.create("tool_policy", { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + id: String(id), + pattern: input.pattern, + action: input.action, + position, created_at: now, updated_at: now, }); - return credentialBindingRowToRef({ - id, - scope_id: input.targetScope, - plugin_id: input.pluginId, - source_id: input.sourceId, - source_scope_id: input.sourceScope, - slot_key: input.slotKey, - kind: input.value.kind, - text_value: input.value.kind === "text" ? input.value.text : undefined, - secret_id: input.value.kind === "secret" ? input.value.secretId : undefined, - secret_scope_id: - input.value.kind === "secret" - ? (input.value.secretScopeId ?? input.targetScope) - : undefined, - connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined, - created_at: now, - updated_at: now, - } as CredentialBindingRow); + return rowToToolPolicy(created); }); - const credentialBindingRemove = (input: RemoveCredentialBindingInput) => + const policiesUpdate = ( + input: UpdateToolPolicyInput, + ): Effect.Effect => Effect.gen(function* () { - yield* assertScopeInStack("credential binding targetScope", input.targetScope); - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - yield* assertCredentialBindingTargetNotOuter({ - label: "credential binding removal", - targetScope: input.targetScope, - sourceScope: input.sourceScope, - sourceId: input.sourceId, - }); - - const source = yield* findSourceRowAtScope({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - }); - if (!source) { + if (input.pattern !== undefined && !isValidPattern(input.pattern)) { return yield* new StorageError({ - message: - `Cannot remove credential binding for source "${input.sourceId}" ` + - `at scope "${input.sourceScope}": source is not visible.`, + message: `Invalid tool policy pattern: ${input.pattern}`, cause: undefined, }); } - - yield* core.deleteMany("credential_binding", { - where: (b) => - b.and( - b("scope_id", "=", input.targetScope), - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - b("slot_key", "=", input.slotKey), - ), - }); - }); - - const credentialBindingReplaceForSource = (input: ReplaceCredentialBindingsInput) => - Effect.gen(function* () { - yield* assertScopeInStack("credential binding targetScope", input.targetScope); - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - yield* assertCredentialBindingTargetNotOuter({ - label: "credential binding replacement", - targetScope: input.targetScope, - sourceScope: input.sourceScope, - sourceId: input.sourceId, - }); - - const source = yield* findSourceRowAtScope({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - }); - if (!source) { + const where = (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)); + const existing = yield* core.findFirst("tool_policy", { where }); + if (!existing) { return yield* new StorageError({ - message: - `Cannot replace credential bindings for source "${input.sourceId}" ` + - `at scope "${input.sourceScope}": source is not visible.`, + message: `Tool policy not found: ${input.id}`, cause: undefined, }); } - - const nextSlots = new Set(input.bindings.map((binding) => binding.slotKey)); - const existing = yield* core.findMany("credential_binding", { - where: (b) => - b.and( - b("scope_id", "=", input.targetScope), - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - ), - }); - for (const row of existing as readonly CredentialBindingRow[]) { - const shouldOwnSlot = input.slotPrefixes.some((prefix) => - row.slot_key.startsWith(prefix), - ); - if (shouldOwnSlot && !nextSlots.has(row.slot_key)) { - yield* credentialBindingRemove({ - targetScope: input.targetScope, - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: row.slot_key, - }); + const set: Record = { updated_at: new Date() }; + if (input.pattern !== undefined) set.pattern = input.pattern; + if (input.action !== undefined) set.action = input.action; + if (input.position !== undefined) set.position = input.position; + yield* core.updateMany("tool_policy", { where, set }); + const updated = yield* core.findFirst("tool_policy", { where }); + return rowToToolPolicy(updated ?? ({ ...existing, ...set } as ToolPolicyRow)); + }); + + const policiesRemove = (input: RemoveToolPolicyInput): Effect.Effect => + core.deleteMany("tool_policy", { + where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)), + }); + + const policiesResolve = ( + address: ToolAddress, + ): Effect.Effect => + Effect.gen(function* () { + const parsed = parseToolAddress(String(address)); + const policyRows = yield* core.findMany("tool_policy", {}); + const toolId = parsed + ? `${parsed.integration}.${parsed.owner}.${parsed.connection}.${parsed.tool}` + : String(address); + // Find the tool to read its default approval annotation. + let requiresApproval: boolean | undefined; + if (parsed) { + const row = yield* core.findFirst("tool", { + where: (b: AnyCb) => + b.and( + byOwner(parsed.owner)(b), + b("integration", "=", String(parsed.integration)), + b("connection", "=", String(parsed.connection)), + b("name", "=", String(parsed.tool)), + ), + }); + if (row) { + const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; + requiresApproval = annotations?.requiresApproval; } } - - const refs: CredentialBindingRef[] = []; - for (const binding of input.bindings) { - refs.push( - yield* credentialBindingSet({ - targetScope: input.targetScope, - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: binding.slotKey, - value: binding.value, - }), - ); - } - return refs; + return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval); }); - const credentialBindingRemoveForSource = (input: CredentialBindingSourceInput) => - Effect.gen(function* () { - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - const source = yield* findSourceRowAtScope(input); - if (!source) return; - - // Source-owner cleanup is intentionally broader than a normal scoped - // binding delete. Removing a shared source must detach all credential - // rows for that source identity, including user-owned bindings that - // are not in the source owner's current stack. - yield* core.deleteMany("credential_binding", { - where: (b) => - b.and( - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - ), - }); - }); + // ------------------------------------------------------------------ + // Elicitation + // ------------------------------------------------------------------ - const credentialBindingResolutionStatus = ( - row: CredentialBindingRow, - ): Effect.Effect<"resolved" | "missing", StorageFailure> => - Effect.gen(function* () { - if (row.kind === "text") return typeof row.text_value === "string" ? "resolved" : "missing"; - if (row.kind === "secret") { - if (!row.secret_id) return "missing"; - const secret = yield* findSecretRowAtScope({ - secretId: row.secret_id, - scopeId: row.secret_scope_id ?? row.scope_id, - }); - if (!secret) return "missing"; - return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing"; - } - if (row.kind === "connection") { - if (!row.connection_id) return "missing"; - const connection = yield* findConnectionRowAtScope({ - connectionId: row.connection_id, - scopeId: row.scope_id, - }); - return connection ? "resolved" : "missing"; - } - return "missing"; - }); + const defaultElicitationHandler = resolveElicitationHandler(config.onElicitation); - const credentialBindingResolveBinding = (input: CredentialBindingSlotInput) => - Effect.gen(function* () { - const rows = yield* credentialBindingRowsForSlot(input); - const row = findInnermost(rows); - return row ? credentialBindingRowToRef(row) : null; - }); + const pickHandler = (options: InvokeOptions | undefined): ElicitationHandler => + options?.onElicitation + ? resolveElicitationHandler(options.onElicitation) + : defaultElicitationHandler; - const credentialBindingResolve = (input: CredentialBindingSlotInput) => - Effect.gen(function* () { - const rows = yield* credentialBindingRowsForSlot(input); - const row = findInnermost(rows); - if (!row) { - return ResolvedCredentialSlot.make({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScopeId: input.sourceScope, - slotKey: input.slotKey, - bindingScopeId: null, - kind: null, - status: "missing" as const, - }); - } - return ResolvedCredentialSlot.make({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScopeId: input.sourceScope, - slotKey: input.slotKey, - bindingScopeId: ScopeId.make(row.scope_id), - kind: - row.kind === "text" || row.kind === "secret" || row.kind === "connection" - ? row.kind - : null, - status: yield* credentialBindingResolutionStatus(row), - }); - }); - - const sourceNamesForCredentialBindings = ( - rows: readonly CredentialBindingRow[], - ): Effect.Effect, StorageFailure> => - Effect.gen(function* () { - const sourceIds = [...new Set(rows.map((row) => row.source_id))]; - if (sourceIds.length === 0) return new Map(); - const sourceRows = yield* core.findMany("source", { - where: scopedWhere(scopeIds, (b) => b("id", "in", sourceIds)), - }); - return new Map( - sourceRows.map((row) => [`${row.scope_id}\u0000${row.id}`, row.name] as const), - ); - }); - - const credentialBindingRowsToUsages = ( - rows: readonly CredentialBindingRow[], - ): Effect.Effect => - Effect.gen(function* () { - const names = yield* sourceNamesForCredentialBindings(rows); - return rows.map((row) => - Usage.make({ - pluginId: row.plugin_id, - scopeId: ScopeId.make( - row.kind === "secret" ? (row.secret_scope_id ?? row.scope_id) : row.scope_id, - ), - ownerKind: "credential-binding", - ownerId: row.source_id, - ownerName: names.get(`${row.source_scope_id}\u0000${row.source_id}`) ?? null, - slot: row.slot_key, - }), - ); - }); - - const credentialBindingUsagesForSecret = ( - id: string, - ): Effect.Effect => - Effect.gen(function* () { - const rows = yield* core.findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => b("secret_id", "=", id)), - }); - return yield* credentialBindingRowsToUsages(rows as readonly CredentialBindingRow[]); - }); - - const credentialBindingUsagesForConnection = ( - id: string, - ): Effect.Effect => - Effect.gen(function* () { - const rows = yield* core.findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => b("connection_id", "=", id)), - }); - return yield* credentialBindingRowsToUsages(rows as readonly CredentialBindingRow[]); - }); - - const credentialBindings: CredentialBindingsFacade = { - listForSource: credentialBindingListForSource, - resolveBinding: credentialBindingResolveBinding, - resolve: credentialBindingResolve, - set: credentialBindingSet, - remove: credentialBindingRemove, - replaceForSource: credentialBindingReplaceForSource, - removeForSource: credentialBindingRemoveForSource, - usagesForSecret: credentialBindingUsagesForSecret, - usagesForConnection: credentialBindingUsagesForConnection, - }; - - const credentialBindingInputForSource = (input: SourceCredentialBindingSourceInput) => - Effect.gen(function* () { - const source = yield* findSourceOwnerRowAtScope({ - sourceId: input.source.id, - sourceScope: input.source.scope, - }); - return source - ? ({ - pluginId: source.plugin_id, - sourceId: input.source.id, - sourceScope: input.source.scope, - } satisfies CredentialBindingSourceInput) - : null; - }); - - const sourceBindingList = (input: SourceCredentialBindingSourceInput) => - Effect.gen(function* () { - const bindingInput = yield* credentialBindingInputForSource(input); - return bindingInput ? yield* credentialBindingListForSource(bindingInput) : []; - }); - - const sourceBindingResolve = (input: SourceCredentialBindingSlotInput) => - Effect.gen(function* () { - const bindingInput = yield* credentialBindingInputForSource(input); - return bindingInput - ? yield* credentialBindingResolveBinding({ - ...bindingInput, - slotKey: input.slotKey, - }) - : null; - }); - - const sourceBindingSet = (input: SetSourceCredentialBindingInput) => - Effect.gen(function* () { - const bindingInput = yield* credentialBindingInputForSource(input); - if (!bindingInput) { - return yield* new StorageError({ - message: - `Cannot set credential binding for source "${input.source.id}" ` + - `at scope "${input.source.scope}": source is not visible.`, - cause: undefined, - }); - } - return yield* credentialBindingSet({ - ...bindingInput, - targetScope: input.scope, - slotKey: input.slotKey, - value: input.value, - }); - }); - - const sourceBindingRemove = (input: RemoveSourceCredentialBindingInput) => - Effect.gen(function* () { - const bindingInput = yield* credentialBindingInputForSource(input); - if (!bindingInput) { - return yield* new StorageError({ - message: - `Cannot remove credential binding for source "${input.source.id}" ` + - `at scope "${input.source.scope}": source is not visible.`, - cause: undefined, - }); - } - yield* credentialBindingRemove({ - ...bindingInput, - targetScope: input.scope, - slotKey: input.slotKey, - }); - }); - - const sourceBindingReplace = (input: ReplaceSourceCredentialBindingsInput) => - Effect.gen(function* () { - const bindingInput = yield* credentialBindingInputForSource(input); - if (!bindingInput) { - return yield* new StorageError({ - message: - `Cannot replace credential bindings for source "${input.source.id}" ` + - `at scope "${input.source.scope}": source is not visible.`, - cause: undefined, - }); - } - return yield* credentialBindingReplaceForSource({ - ...bindingInput, - targetScope: input.scope, - slotPrefixes: input.slotPrefixes, - bindings: input.bindings, - }); - }); - - const sourceConfigure = (input: { - readonly source: { - readonly id: string; - readonly scope: ScopeId | string; - }; - readonly scope: ScopeId | string; - readonly type?: string; - readonly config: unknown; - }) => - Effect.gen(function* () { - yield* assertScopeInStack("source configure source scope", input.source.scope); - yield* assertScopeInStack("source configure target scope", input.scope); - - const source = yield* core.findFirst("source", { - where: byScopedId(input.source.scope, input.source.id), - }); - if (!source) { - return yield* new StorageError({ - message: - `Cannot configure source "${input.source.id}" at scope ` + - `"${input.source.scope}": source is not visible.`, - cause: undefined, - }); - } - - const runtime = runtimes.get(source.plugin_id); - const configure = runtime?.plugin.sourceConfigure; - if (!runtime || !configure) { - return yield* new StorageError({ - message: `Plugin "${source.plugin_id}" does not support source.configure.`, - cause: undefined, - }); - } - if (input.type !== undefined && input.type !== configure.type) { - return yield* new StorageError({ - message: - `Source configure type mismatch for plugin "${source.plugin_id}": ` + - `expected "${configure.type}", received "${input.type}".`, - cause: undefined, - }); - } - - const decoded = yield* decodeConfigureInput(configure.schema, input.config).pipe( - Effect.mapError((cause) => - storageFailureFromUnknown( - `Invalid source.configure payload for ${configure.type}`, - cause, - ), - ), - ); - - return yield* configure - .configure({ - ctx: runtime.ctx, - sourceId: input.source.id, - sourceScope: input.source.scope, - targetScope: input.scope, - config: decoded, - }) - .pipe( - Effect.mapError((cause) => - pluginStorageFailure(source.plugin_id, "sourceConfigure", cause), - ), - ); - }); - - const oauthBundle = makeOAuth2Service({ - fuma, - secretsGet: (id) => - secretsGet(id).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => Effect.succeed(null)), - ), - secretsGetResolved: (id) => secretsGetResolved(id), - secretsGetAtScope: (id, scope) => - secretsGetAtScope(id, scope).pipe( - Effect.catchTag("SecretOwnedByConnectionError", () => Effect.succeed(null)), - ), - secretsSet: (input) => secretsSet(input), - connectionsCreate: (input) => connectionsCreate(input), - connectionsGet: (id) => connectionsGet(id), - httpClientLayer: config.httpClientLayer, - endpointUrlPolicy: config.oauthEndpointUrlPolicy, - }); - connectionProviders.set(oauthBundle.connectionProvider.key, oauthBundle.connectionProvider); - - // ------------------------------------------------------------------ - // Plugin wiring — build ctx, run extension, populate static pools, - // register secret providers. No adapter reads here. - // ------------------------------------------------------------------ - for (const plugin of plugins) { - if (runtimes.has(plugin.id)) { - return yield* new StorageError({ - message: `Duplicate plugin id: ${plugin.id}`, - cause: undefined, - }); - } - - const pluginStorage = makePluginStorageFacade({ - core, - pluginId: plugin.id, - scopeIds, - }); - const storageDeps: StorageDeps = { - scopes, - // Blob keys are namespaced by `/` so two tenants - // sharing a backing BlobStore can't collide or leak on the - // same `(plugin, key)` pair. The store's `get`/`has` walk the - // scope stack (innermost first); `put`/`delete` require the - // plugin to name a target scope explicitly. - blobs: pluginBlobStore(blobs, scopeIds, plugin.id), - pluginStorage, - }; - const storage = plugin.storage(storageDeps); - - const ctx: PluginCtx = { - scopes, - storage, - pluginStorage, - httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer, - core: { - sources: { - register: (input: SourceInput) => - Effect.gen(function* () { - // Guard: reject a dynamic source whose id collides with - // a static source id, or any of whose would-be tool ids - // collide with a static tool id. Tool ids are - // `${source_id}.${tool.name}` — static and dynamic - // share the same string space. Fails as `StorageError` - // so the HTTP edge surfaces it as `InternalError(traceId)`. - if (staticSources.has(input.id)) { - return yield* new StorageError({ - message: `Source id "${input.id}" collides with a static source`, - cause: undefined, - }); - } - for (const tool of input.tools) { - const fqid = `${input.id}.${tool.name}`; - if (staticTools.has(fqid)) { - return yield* new StorageError({ - message: `Tool id "${fqid}" collides with a static tool`, - cause: undefined, - }); - } - } - yield* transaction(writeSourceInput(core, plugin.id, input)); - }), - unregister: (input: RemoveSourceInput) => - // `unregister` is scoped to a caller-named source row. The - // plugin already knows which source owner it is updating, - // so the core path must not infer an innermost target. - transaction( - Effect.gen(function* () { - yield* assertScopeInStack("source unregister targetScope", input.targetScope); - const row = yield* core.findFirst("source", { - where: byScopedId(input.targetScope, input.id), - }); - if (!row) return; - yield* deleteSourceById(core, input.id, input.targetScope); - }), - ), - update: (input) => - core - .updateMany("source", { - where: byScopedId(input.scope, input.id), - set: { - ...(input.name !== undefined ? { name: input.name } : {}), - ...(input.url !== undefined ? { url: input.url ?? null } : {}), - updated_at: new Date(), - }, - }) - .pipe(Effect.asVoid), - list: () => listSources(), - remove: (input) => removeSource(input), - refresh: (input) => refreshSource(input), - detect: (url) => detectSource(url), - configure: (input) => sourceConfigure(input), - listBindings: (input) => sourceBindingList(input), - resolveBinding: (input) => sourceBindingResolve(input), - setBinding: (input) => sourceBindingSet(input), - removeBinding: (input) => sourceBindingRemove(input), - configureSchemas: () => - Array.from(runtimes.values()) - .map(({ plugin }) => - plugin.sourceConfigure - ? sourceConfigureSchemaView(plugin.id, plugin.sourceConfigure) - : undefined, - ) - .filter(Predicate.isNotUndefined), - presets: () => - Array.from(runtimes.values()).flatMap(({ plugin }) => - (plugin.sourcePresets ?? []).map((preset) => ({ - ...preset, - pluginId: plugin.id, - })), - ), - }, - policies: { - list: () => policiesList(), - create: (input) => policiesCreate(input), - update: (input) => policiesUpdate(input), - remove: (input) => policiesRemove(input), - }, - definitions: { - register: (input: DefinitionsInput) => - transaction(writeDefinitions(core, plugin.id, input)), - }, - }, - secrets: { - get: (id) => secretsGet(id), - getAtScope: (id, scope) => secretsGetAtScope(id, scope), - list: () => secretsListForCtx(), - status: (id) => secretsStatus(id), - usages: (id) => secretsUsages(id), - providers: () => - Effect.sync(() => Array.from(secretProviders.keys()) as readonly string[]), - set: (input) => secretsSet(input), - remove: (input) => secretsRemove(input), - }, - connections: { - get: (id) => connectionsGet(id), - getAtScope: (id, scope) => connectionsGetAtScope(id, scope), - list: () => connectionsListForCtx(), - usages: (id) => connectionsUsages(id), - providers: () => - Effect.sync(() => Array.from(connectionProviders.keys()) as readonly string[]), - create: (input) => connectionsCreate(input), - updateTokens: (input) => connectionsUpdateTokens(input), - setIdentityLabel: (id, label) => connectionsSetIdentityLabel(id, label), - setIdentityOverride: (input) => connectionsSetIdentityOverride(input), - accessToken: (id) => connectionsAccessToken(id), - accessTokenAtScope: (id, scope) => connectionsAccessTokenAtScope(id, scope), - remove: (input) => connectionsRemove(input), - }, - credentialBindings, - oauth: oauthBundle.service, - transaction: (effect: Effect.Effect) => transaction(effect), - }; - - // Build extension FIRST so it's available as `self` when resolving - // staticSources. Field ordering in the plugin spec matters — TS - // infers TExtension from `extension`'s return type, then NoInfer - // locks `self` to that inferred type on `staticSources`. - const extension: object = plugin.extension ? plugin.extension(ctx) : {}; - if (plugin.extension) { - extensions[plugin.id] = extension; - } - - // Resolve static declarations to the in-memory pools. NO DB WRITES. - // Plugin-owned executor tools are intentionally mounted under the - // single `executor` namespace so source inventory is about configured - // integrations, not plugin management surfaces. The static source id - // becomes the path segment, so plugins can expose TypeScript-friendly - // management namespaces without changing their persisted plugin ids: - // openapi.addSource -> executor.openapi.addSource - const decls = plugin.staticSources ? plugin.staticSources(extension) : []; - for (const source of decls) { - const mountUnderExecutor = source.kind === "executor"; - const mountedSource = mountUnderExecutor ? EXECUTOR_SOURCE : source; - - if (mountUnderExecutor) { - if (!staticSources.has(EXECUTOR_SOURCE_ID)) { - staticSources.set(EXECUTOR_SOURCE_ID, { - source: EXECUTOR_SOURCE, - pluginId: EXECUTOR_SOURCE_ID, - }); - } - } else { - if (staticSources.has(source.id)) { - return yield* new StorageError({ - message: `Duplicate static source id: ${source.id} (plugin ${plugin.id})`, - cause: undefined, - }); - } - staticSources.set(source.id, { source, pluginId: plugin.id }); - } - - for (const tool of source.tools) { - const mountedTool = mountUnderExecutor - ? { - ...tool, - name: `${source.id}.${tool.name}`, - } - : tool; - const fqid = `${mountedSource.id}.${mountedTool.name}`; - if (staticTools.has(fqid)) { - return yield* new StorageError({ - message: `Duplicate static tool id: ${fqid} (plugin ${plugin.id})`, - cause: undefined, - }); - } - staticTools.set(fqid, { - source: mountedSource, - tool: mountedTool, - pluginId: plugin.id, - ctx, - }); - } - } - - runtimes.set(plugin.id, { plugin, storage, ctx }); - - if (plugin.secretProviders) { - const raw = - typeof plugin.secretProviders === "function" - ? plugin.secretProviders(ctx) - : plugin.secretProviders; - const providers = Effect.isEffect(raw) - ? yield* raw.pipe( - Effect.mapError((cause) => pluginStorageFailure(plugin.id, "secretProviders", cause)), - ) - : raw; - for (const provider of providers) { - if (secretProviders.has(provider.key)) { - return yield* new StorageError({ - message: `Duplicate secret provider key: ${provider.key} (from plugin ${plugin.id})`, - cause: undefined, - }); - } - secretProviders.set(provider.key, provider); - } - } - - if (plugin.connectionProviders) { - const raw = - typeof plugin.connectionProviders === "function" - ? plugin.connectionProviders(ctx) - : plugin.connectionProviders; - const providers = Effect.isEffect(raw) - ? yield* raw.pipe( - Effect.mapError((cause) => - pluginStorageFailure(plugin.id, "connectionProviders", cause), - ), - ) - : raw; - for (const provider of providers) { - if (connectionProviders.has(provider.key)) { - return yield* new StorageError({ - message: `Duplicate connection provider key: ${provider.key} (from plugin ${plugin.id})`, - cause: undefined, - }); - } - connectionProviders.set(provider.key, provider); - } - } - } - - // ------------------------------------------------------------------ - // Executor surface - // ------------------------------------------------------------------ - const listSources = () => - Effect.gen(function* () { - const dynamic = yield* core.findMany("source", { where: scopedWhere(scopeIds) }); - // Dedup by id with innermost scope winning. Without this, a user - // who shadowed an org-wide source at their inner scope would see - // two rows — their override and the outer default — which is - // inconsistent with how `secrets.list` and every other list - // surface dedup shadowed entries. - const byId = new Map(); - const byIdRank = new Map(); - for (const row of dynamic) { - const rank = scopeRank(row); - const existing = byIdRank.get(row.id); - if (existing === undefined || rank < existing) { - byId.set(row.id, row); - byIdRank.set(row.id, rank); - } - } - const dynamicDeduped = [...byId.values()]; - const sourceKeys = new Set(dynamicDeduped.map((row) => `${row.scope_id}\u0000${row.id}`)); - const sourceConnectionIds = new Map(); - if (sourceKeys.size > 0) { - const bindingRows = yield* core.findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => b("kind", "=", "connection")), - }); - for (const row of bindingRows as readonly CredentialBindingRow[]) { - if (!row.connection_id) continue; - const key = `${String(row.source_scope_id)}\u0000${String(row.source_id)}`; - if (!sourceKeys.has(key)) continue; - const connectionId = String(row.connection_id); - const values = sourceConnectionIds.get(key) ?? []; - if (!values.includes(connectionId)) values.push(connectionId); - sourceConnectionIds.set(key, values); - } - } - const staticList: Source[] = []; - for (const { source, pluginId } of staticSources.values()) { - staticList.push(staticDeclToSource(source, pluginId)); - } - const merged = [ - ...staticList, - ...dynamicDeduped.map((row) => - rowToSource(row, sourceConnectionIds.get(`${row.scope_id}\u0000${row.id}`) ?? []), - ), - ]; - yield* Effect.annotateCurrentSpan({ - "executor.sources.static_count": staticList.length, - "executor.sources.dynamic_count": dynamicDeduped.length, - }); - return merged; - }).pipe(Effect.withSpan("executor.sources.list")); - - // Bulk-resolve annotations across a set of dynamic tool rows by - // grouping them under their owning plugin's resolveAnnotations - // callback. One plugin call per (plugin_id, source_id) pair, not - // per row. Plugins without a resolver simply contribute no - // annotations for their rows. - const resolveAnnotationsFor = (rows: readonly ToolRow[]) => - Effect.gen(function* () { - const result = new Map(); - if (rows.length === 0) return result; - - // Group by (plugin_id, source_id) - const groups = new Map(); - for (const row of rows) { - const key = `${row.plugin_id}\u0000${row.source_id}`; - const bucket = groups.get(key); - if (bucket) bucket.push(row); - else groups.set(key, [row]); - } - - // Each (plugin_id, source_id) group is an independent DB read, - // so fan them out concurrently. Yielding them serially stacks - // ~200-300ms storage round-trips end-to-end and dominates the - // `executor.tools.list.annotations` span. - const maps = yield* Effect.forEach( - [...groups].slice(0, MAX_ANNOTATION_GROUPS), - ([key, groupRows]) => - Effect.gen(function* () { - const [pluginId, sourceId] = key.split("\u0000") as [string, string]; - const runtime = runtimes.get(pluginId); - if (!runtime?.plugin.resolveAnnotations) return undefined; - return yield* runtime.plugin - .resolveAnnotations({ - ctx: runtime.ctx, - sourceId, - toolRows: groupRows, - }) - .pipe( - Effect.mapError((cause) => - pluginStorageFailure(pluginId, "resolveAnnotations", cause), - ), - ); - }), - { concurrency: "unbounded" }, - ); - for (const map of maps) { - if (!map) continue; - for (const [toolId, annotations] of Object.entries(map)) { - result.set(toolId, annotations); - } - } - return result; - }); - - const listTools = (filter?: ToolListFilter) => - Effect.gen(function* () { - const dynamic = yield* core.findMany("tool", { - where: scopedWhere( - scopeIds, - filter?.sourceId ? (b) => b("source_id", "=", filter.sourceId!) : undefined, - ), - }); - // Dedup by tool id, innermost scope winning — same reason as - // `listSources` above: a shadowed id must surface as one entry - // (the inner one), not two. - const byId = new Map(); - const byIdRank = new Map(); - for (const row of dynamic) { - const rank = scopeRank(row); - const existing = byIdRank.get(row.id); - if (existing === undefined || rank < existing) { - byId.set(row.id, row); - byIdRank.set(row.id, rank); - } - } - const dynamicDeduped = [...byId.values()]; - const annotations = - filter?.includeAnnotations === false - ? new Map() - : yield* resolveAnnotationsFor(dynamicDeduped).pipe( - Effect.withSpan("executor.tools.list.annotations"), - ); - - const out: ToolView[] = []; - // Static tools — annotations from the declaration, not a resolver. - for (const entry of staticTools.values()) { - out.push(staticDeclToTool(entry.source, entry.tool, entry.pluginId)); - } - for (const row of dynamicDeduped) { - out.push(rowToTool(row, annotations.get(row.id))); - } - const filtered = filter ? out.filter((t) => toolMatchesFilter(t, filter)) : out; - - // Drop tools blocked by user policy unless the caller explicitly - // asked to see them (the settings UI does, agent surfaces don't). - // One findMany covers the entire scope stack; resolution per - // tool is in-memory. - let result = filtered; - let blockedCount = 0; - if (filter?.includeBlocked !== true) { - const policies = yield* loadAllPolicies(); - if (policies.length > 0) { - const kept: ToolView[] = []; - for (const tool of filtered) { - const match = resolveToolPolicy(tool.id, policies, scopeRank); - if (match?.action === "block") { - blockedCount++; - continue; - } - kept.push(tool); - } - result = kept; - } - } - - yield* Effect.annotateCurrentSpan({ - "executor.tools.static_count": staticTools.size, - "executor.tools.dynamic_count": dynamicDeduped.length, - "executor.tools.result_count": result.length, - "executor.tools.blocked_count": blockedCount, - }); - return result; - }).pipe(Effect.withSpan("executor.tools.list")); - - // Load all definitions for a single source as a plain map. Defs - // for the same name can exist at multiple scopes (an admin registers - // a default, a user overrides one entry with a tighter schema) — - // dedup by name keeping the innermost-scope row. - const loadDefinitionsForSource = (sourceId: string) => - Effect.gen(function* () { - const defRows = yield* core.findMany("definition", { - where: scopedWhere(scopeIds, (b) => b("source_id", "=", sourceId)), - }); - const winners = new Map(); - for (const row of defRows) { - const rank = scopeRank(row); - const existing = winners.get(row.name); - if (!existing || rank < existing.rank) { - winners.set(row.name, { row, rank }); - } - } - const out: Record = {}; - for (const [name, { row }] of winners) out[name] = row.schema; - return out; - }); - - // Render the ToolSchemaView view for a tool. Raw JSON schema roots stay small, - // while source-level definitions are returned once for the UI schema - // explorer and passed separately to the TypeScript preview compiler. - const buildToolSchemaView = (opts: { - toolId: string; - name?: string; - description?: string; - sourceId: string | undefined; - rawInput: unknown; - rawOutput: unknown; - }) => - Effect.gen(function* () { - const defs: Record = opts.sourceId - ? yield* loadDefinitionsForSource(opts.sourceId).pipe( - Effect.withSpan("executor.tool.schema.load_defs"), - ) - : {}; - - const sourceDefsMap = new Map(Object.entries(defs)); - const schemaDefinitions = collectReferencedDefinitions( - [opts.rawInput, opts.rawOutput], - sourceDefsMap, - ); - const schemaDefsMap = new Map(Object.entries(schemaDefinitions)); - const preview: ToolTypeScriptPreview = yield* Effect.promise(() => - buildToolTypeScriptPreview({ - inputSchema: opts.rawInput, - outputSchema: opts.rawOutput, - defs: schemaDefsMap, - }), - ).pipe( - Effect.withSpan("schema.compile.preview", { - attributes: { - "schema.kind": "tool.preview", - "schema.has_input": opts.rawInput !== undefined, - "schema.has_output": opts.rawOutput !== undefined, - "schema.def_count": schemaDefsMap.size, - "schema.source_def_count": sourceDefsMap.size, - }, - }), - ); - - return ToolSchemaView.make({ - id: ToolId.make(opts.toolId), - name: opts.name, - description: opts.description, - inputSchema: opts.rawInput, - outputSchema: opts.rawOutput, - schemaDefinitions: - Object.keys(schemaDefinitions).length > 0 ? schemaDefinitions : undefined, - inputTypeScript: preview.inputTypeScript ?? undefined, - outputTypeScript: preview.outputTypeScript ?? undefined, - typeScriptDefinitions: preview.typeScriptDefinitions ?? undefined, - }); - }); - - const toolSchema = (toolId: string) => - Effect.gen(function* () { - // Static pool first — static tools have no source in the DB so - // no `$defs` attach; just wrap the declared schemas. - const staticEntry = staticTools.get(toolId); - if (staticEntry) { - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "static", - "executor.source_id": staticEntry.source.id, - "executor.source_kind": staticEntry.source.kind, - }); - return yield* buildToolSchemaView({ - toolId, - name: staticEntry.tool.name, - description: staticEntry.tool.description, - sourceId: undefined, - rawInput: toToolJsonSchema(staticEntry.tool.inputSchema), - rawOutput: toToolJsonSchema(staticEntry.tool.outputSchema, "output"), - }); - } - // Innermost-wins lookup across every visible scope. - const rows = yield* core - .findMany("tool", { - where: scopedWhere(scopeIds, byId(toolId)), - }) - .pipe(Effect.withSpan("executor.tool.resolve")); - const row = findInnermost(rows); - if (!row) return null; - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "dynamic", - "executor.source_id": row.source_id, - "executor.plugin_id": row.plugin_id, - }); - return yield* buildToolSchemaView({ - toolId, - name: row.name, - description: row.description, - sourceId: row.source_id, - rawInput: decodeJsonColumn(row.input_schema), - rawOutput: decodeJsonColumn(row.output_schema), - }); - }).pipe( - Effect.withSpan("executor.tool.schema", { - attributes: { "mcp.tool.name": toolId }, - }), - ); - - // Bulk definitions accessor — every source's $defs, grouped by - // source id. One query against the definition table, plus an - // in-memory group-by with innermost-scope dedup: if the same - // (source_id, name) pair exists at multiple scopes, the inner - // scope's schema wins. - const toolsDefinitions = () => - Effect.gen(function* () { - const rows = yield* core.findMany("definition", { where: scopedWhere(scopeIds) }); - const winners = new Map(); - for (const row of rows) { - const key = `${row.source_id}\u0000${row.name}`; - const rank = scopeRank(row); - const existing = winners.get(key); - if (!existing || rank < existing.rank) { - winners.set(key, { row, rank }); - } - } - const out: Record> = {}; - for (const { row } of winners.values()) { - let bucket = out[row.source_id]; - if (!bucket) { - bucket = {}; - out[row.source_id] = bucket; - } - bucket[row.name] = row.schema; - } - return out; - }); - - const defaultElicitationHandler = resolveElicitationHandler(config.onElicitation); - const pickHandler = (options: InvokeOptions | undefined): ElicitationHandler => - options?.onElicitation - ? resolveElicitationHandler(options.onElicitation) - : defaultElicitationHandler; - - const buildElicit = (toolId: string, args: unknown, handler: ElicitationHandler): Elicit => { - return (request: ElicitationRequest) => - Effect.gen(function* () { - const tid = ToolId.make(toolId); - const response: ElicitationResponse = yield* handler({ - toolId: tid, - args, - request, + const buildElicit = ( + address: ToolAddress, + args: unknown, + handler: ElicitationHandler, + ): Elicit => { + return (request: ElicitationRequest) => + Effect.gen(function* () { + const response: ElicitationResponse = yield* handler({ + address, + args, + request, }); if (response.action !== "accept") { return yield* new ElicitationDeclinedError({ - toolId: tid, - action: response.action, - }); - } - return response; - }); - }; - - // ------------------------------------------------------------------ - // Tool policies — user-authored overrides of the plugin-derived - // approval annotations. Resolution walks the scope-stacked policy - // table with first-match-wins ordering (innermost scope first, then - // `position` ascending). The result either short-circuits invoke - // (`block`), forces approval (`require_approval`), skips approval - // (`approve`), or returns `undefined` so the plugin annotation is - // used as today. - // ------------------------------------------------------------------ - - const loadAllPolicies = () => core.findMany("tool_policy", { where: scopedWhere(scopeIds) }); - - const resolveToolPolicyForId = (toolId: string) => - Effect.gen(function* () { - const policies = yield* loadAllPolicies(); - return resolveToolPolicy(toolId, policies, scopeRank); - }); + address, + action: response.action, + }); + } + return response; + }); + }; const enforceApproval = ( annotations: ToolAnnotations | undefined, - toolId: string, + address: ToolAddress, args: unknown, - policy: PolicyMatch | undefined, + policy: EffectivePolicy, handler: ElicitationHandler, ) => Effect.gen(function* () { - // approve → never prompt regardless of plugin annotation. - if (policy?.action === "approve") return; - - // require_approval → always prompt. If the plugin already had a - // description, prefer it; otherwise show the matched pattern so - // the user can see *why* the prompt fired. - const policyForcesApproval = policy?.action === "require_approval"; + if (policy.action === "approve") return; + const policyForcesApproval = policy.action === "require_approval"; if (!policyForcesApproval && !annotations?.requiresApproval) return; - - const tid = ToolId.make(toolId); const message = annotations?.approvalDescription ? annotations.approvalDescription - : policyForcesApproval && policy - ? `Approve ${toolId}? (matched policy: ${policy.pattern})` - : `Approve ${toolId}?`; + : policyForcesApproval && policy.pattern + ? `Approve ${address}? (matched policy: ${policy.pattern})` + : `Approve ${address}?`; const request = FormElicitation.make({ message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`, - requestedSchema: { - type: "object", - properties: {}, - }, + requestedSchema: { type: "object", properties: {} }, }); - const response = yield* handler({ toolId: tid, args, request }); + const response = yield* handler({ address, args, request }); if (response.action !== "accept") { return yield* new ElicitationDeclinedError({ - toolId: tid, + address, action: response.action, }); } }); - const invokeTool = (toolId: string, args: unknown, options?: InvokeOptions) => { + // ------------------------------------------------------------------ + // execute — the invoke path. + // ------------------------------------------------------------------ + + const TOOL_SUGGESTION_LIMIT = 5; + + const toolSuggestions = (rows: readonly ToolRow[]): readonly ToolAddress[] => + rows.map((row) => rowToTool(row).address); + + const toolRowsForConnectionWhere = (parsed: ParsedToolAddress) => (b: AnyCb) => + b.and( + byOwner(parsed.owner)(b), + b("integration", "=", String(parsed.integration)), + b("connection", "=", String(parsed.connection)), + ); + + const searchToolRowsForConnection = ( + parsed: ParsedToolAddress, + ): Effect.Effect => + core.findMany("tool", { + where: (b: AnyCb) => + b.and( + toolRowsForConnectionWhere(parsed)(b), + b.or( + b("name", "contains", String(parsed.tool)), + b("description", "contains", String(parsed.tool)), + ), + ), + orderBy: ["name", "asc"], + limit: TOOL_SUGGESTION_LIMIT, + }); + + const findToolRowsForConnection = ( + parsed: ParsedToolAddress, + ): Effect.Effect => + core.findMany("tool", { + where: toolRowsForConnectionWhere(parsed), + orderBy: ["name", "asc"], + limit: TOOL_SUGGESTION_LIMIT, + }); + + const execute = ( + address: ToolAddress, + args: unknown, + options?: InvokeOptions, + ): Effect.Effect => { const handler = pickHandler(options); return Effect.gen(function* () { const formatInvocationCauseMessage = (cause: unknown): string => { - // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: preserve public invoke error message wrapping for unknown plugin failures + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: preserve public execute error message wrapping for unknown plugin failures return cause instanceof Error ? cause.message : String(cause); }; - const wrapInvocationError = - (resolvedToolId: string) => - (effect: Effect.Effect): Effect.Effect => - effect.pipe( - Effect.mapError( - (cause) => - new ToolInvocationError({ - toolId: ToolId.make(resolvedToolId), - message: formatInvocationCauseMessage(cause), - cause, - }), - ), - ); + const wrapInvocationError = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.mapError( + (cause) => + new ToolInvocationError({ + address, + message: formatInvocationCauseMessage(cause), + cause, + }), + ), + ); - // Static path — O(1) map lookup, no DB hit. - const staticEntry = staticTools.get(toolId); + // Static path — O(1) map lookup for plugin-contributed static tools + // (core-tools, plugin executor namespaces). Addressed by their fqid, + // not the 5-segment dynamic form. + const staticEntry = staticTools.get(String(address)); if (staticEntry) { - // Resolve the user-authored policy before static plugin code - // runs. Dynamic tools resolve policy after canonicalizing the - // stored tool id so casing aliases cannot bypass rules. - const policy = yield* resolveToolPolicyForId(toolId).pipe( - Effect.withSpan("executor.tool.resolve_policy"), + const policyRows = yield* core.findMany("tool_policy", {}); + const policy = resolveEffectivePolicy( + String(address), + policyRows, + ownerRankForRow, + staticEntry.tool.annotations?.requiresApproval, ); - if (policy?.action === "block") { + if (policy.action === "block") { return yield* new ToolBlockedError({ - toolId: ToolId.make(toolId), - pattern: policy.pattern, + address, + pattern: policy.pattern ?? "*", }); } - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "static", - "executor.source_id": staticEntry.source.id, - "executor.source_kind": staticEntry.source.kind, - "executor.plugin_id": staticEntry.pluginId, - }); - yield* enforceApproval(staticEntry.tool.annotations, toolId, args, policy, handler).pipe( - Effect.withSpan("executor.tool.enforce_approval"), - ); - return yield* wrapInvocationError(toolId)( + yield* enforceApproval(staticEntry.tool.annotations, address, args, policy, handler); + return yield* wrapInvocationError( staticEntry.tool.handler({ ctx: staticEntry.ctx, args, - elicit: buildElicit(toolId, args, handler), + elicit: buildElicit(address, args, handler), }), - ).pipe(Effect.withSpan("executor.tool.handler")); + ); } - // Dynamic path — DB lookup + delegate to owning plugin. Walk the - // whole scope stack and pick the innermost-scope row so a user's - // shadow of an outer tool actually wins on invoke. - let toolRows = yield* core - .findMany("tool", { - where: scopedWhere(scopeIds, byId(toolId)), - }) - .pipe(Effect.withSpan("executor.tool.resolve")); - let row = findInnermost(toolRows); - let resolvedToolId = toolId; - let suggestionRows: readonly CoreRow<"tool">[] = toolRows; - if (!row) { - suggestionRows = yield* core - .findMany("tool", { - where: scopedWhere(scopeIds), - }) - .pipe(Effect.withSpan("executor.tool.resolve_suggestions")); - const sourceId = toolSourceId(toolId); - if (sourceId) { - const normalizedToolId = toolId.toLowerCase(); - row = findInnermost( - suggestionRows.filter( - (toolRow) => - toolRow.source_id === sourceId && toolRow.id.toLowerCase() === normalizedToolId, - ), - ); - if (row) resolvedToolId = row.id; - } + const parsed = parseToolAddress(String(address)); + if (!parsed) { + return yield* new ToolNotFoundError({ address }); } + + // Find the tool row. + const row = yield* core.findFirst("tool", { + where: (b: AnyCb) => + b.and( + byOwner(parsed.owner)(b), + b("integration", "=", String(parsed.integration)), + b("connection", "=", String(parsed.connection)), + b("name", "=", String(parsed.tool)), + ), + }); if (!row) { + const searchMatches = yield* searchToolRowsForConnection(parsed); + const connectionTools = + searchMatches.length > 0 ? searchMatches : yield* findToolRowsForConnection(parsed); return yield* new ToolNotFoundError({ - toolId: ToolId.make(toolId), - suggestions: missingToolSuggestions(toolId, suggestionRows), + address, + suggestions: toolSuggestions(connectionTools), }); } - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "dynamic", - "executor.source_id": row.source_id, - "executor.plugin_id": row.plugin_id, - "executor.tool.resolved_id": resolvedToolId, - }); - const policy = yield* resolveToolPolicyForId(resolvedToolId).pipe( - Effect.withSpan("executor.tool.resolve_policy"), + + // Resolve policy (owner-ranked). + const toolForPolicy = rowToTool(row); + const policyRows = yield* core.findMany("tool_policy", {}); + const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined; + const policy = resolveEffectivePolicy( + normalizedPolicyId(toolForPolicy), + policyRows, + ownerRankForRow, + annotations?.requiresApproval, ); - if (policy?.action === "block") { + if (policy.action === "block") { return yield* new ToolBlockedError({ - toolId: ToolId.make(resolvedToolId), - pattern: policy.pattern, + address, + pattern: policy.pattern ?? "*", }); } + const runtime = runtimes.get(row.plugin_id); if (!runtime) { return yield* new PluginNotLoadedError({ + address, pluginId: row.plugin_id, - toolId: ToolId.make(toolId), }); } if (!runtime.plugin.invokeTool) { return yield* new NoHandlerError({ - toolId: ToolId.make(toolId), + address, pluginId: row.plugin_id, }); } - // Ask the plugin to derive annotations for this one row, if it - // has a resolver. Cheap because the plugin typically already - // needs to load its enrichment data to invoke the tool — - // implementations should structure their resolver + invokeTool - // around a single storage read. Skipped entirely when the user - // policy is `approve` — the prompt is going to be skipped no - // matter what the plugin says, so don't pay for the lookup. - let annotations: ToolAnnotations | undefined; - if (policy?.action !== "approve" && runtime.plugin.resolveAnnotations) { + // Find the connection row. + const connectionRow = yield* findConnectionRow({ + owner: parsed.owner, + integration: parsed.integration, + name: parsed.connection, + }); + if (!connectionRow) { + return yield* new ConnectionNotFoundError({ + owner: parsed.owner, + integration: parsed.integration, + name: parsed.connection, + }); + } + + // Resolve annotations + enforce approval. + let resolvedAnnotations = annotations; + if (policy.action !== "approve" && runtime.plugin.resolveAnnotations) { const map = yield* runtime.plugin .resolveAnnotations({ ctx: runtime.ctx, - sourceId: row.source_id, + integration: parsed.integration, + connection: parsed.connection, toolRows: [row], }) - .pipe(wrapInvocationError(resolvedToolId)) - .pipe(Effect.withSpan("executor.tool.resolve_annotations")); - annotations = map[resolvedToolId]; - } - yield* enforceApproval(annotations, resolvedToolId, args, policy, handler).pipe( - Effect.withSpan("executor.tool.enforce_approval"), - ); + .pipe(wrapInvocationError); + resolvedAnnotations = map[String(parsed.tool)] ?? annotations; + } + yield* enforceApproval(resolvedAnnotations, address, args, policy, handler); + + // Resolve every named credential input (`variable → value`); `value` is + // the primary `token` for single-input + OAuth callers. + const values = yield* resolveConnectionValues(connectionRow); + const integrationRow = yield* findIntegrationRow(parsed.integration); + const credential: ToolInvocationCredential = { + owner: parsed.owner, + integration: parsed.integration, + connection: parsed.connection, + template: AuthTemplateSlug.make(connectionRow.template), + value: values[PRIMARY_INPUT_VARIABLE] ?? null, + values, + config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined, + }; - return yield* wrapInvocationError(resolvedToolId)( + return yield* wrapInvocationError( runtime.plugin.invokeTool({ ctx: runtime.ctx, toolRow: row, + credential, args, - elicit: buildElicit(resolvedToolId, args, handler), + elicit: buildElicit(address, args, handler), }), - ).pipe(Effect.withSpan("executor.tool.handler")); + ); }).pipe( - Effect.withSpan("executor.tool.invoke", { - attributes: { - "mcp.tool.name": toolId, - }, + Effect.withSpan("executor.tool.execute", { + attributes: { "mcp.tool.name": String(address) }, }), ); }; - const removeSource = (input: RemoveSourceInput) => - Effect.gen(function* () { - yield* assertScopeInStack("source remove targetScope", input.targetScope); - const sourceId = input.id; - // Block removal of static sources structurally. - if (staticSources.has(sourceId)) { - return yield* new SourceRemovalNotAllowedError({ sourceId }); - } - const sourceRow = yield* core.findFirst("source", { - where: byScopedId(input.targetScope, sourceId), - }); - if (!sourceRow) return; - if (!sourceRow.can_remove) { - return yield* new SourceRemovalNotAllowedError({ sourceId }); - } - const runtime = runtimes.get(sourceRow.plugin_id); - // Group the plugin's own cleanup + the core row delete into one - // Fuma transaction so removeSource never leaves orphan rows on failure. - yield* transaction( - Effect.gen(function* () { - if (runtime?.plugin.removeSource) { - yield* runtime.plugin - .removeSource({ - ctx: runtime.ctx, - sourceId, - scope: input.targetScope, - }) - .pipe( - Effect.mapError((cause) => - pluginStorageFailure(runtime.plugin.id, "removeSource", cause), - ), - ); - } - yield* deleteSourceById(core, sourceId, input.targetScope); - }), - ); - }); - - const refreshSource = (input: RefreshSourceInput) => - Effect.gen(function* () { - yield* assertScopeInStack("source refresh targetScope", input.targetScope); - const sourceId = input.id; - if (staticSources.has(sourceId)) return; - const sourceRow = yield* core.findFirst("source", { - where: byScopedId(input.targetScope, sourceId), - }); - if (!sourceRow) return; - const runtime = runtimes.get(sourceRow.plugin_id); - if (runtime?.plugin.refreshSource) { - yield* runtime.plugin - .refreshSource({ - ctx: runtime.ctx, - sourceId, - scope: input.targetScope, - }) - .pipe( - Effect.mapError((cause) => - pluginStorageFailure(runtime.plugin.id, "refreshSource", cause), - ), - ); - } - }); - - const sourceDetectionMaxUrlLength = config.sourceDetection?.maxUrlLength ?? 2_048; - const sourceDetectionMaxDetectors = config.sourceDetection?.maxDetectors ?? 6; - const sourceDetectionMaxResults = config.sourceDetection?.maxResults ?? 4; - const sourceDetectionTimeout = config.sourceDetection?.timeout ?? "60 seconds"; - const sourceDetectionHostedOutboundPolicy = - config.sourceDetection?.hostedOutboundPolicy ?? config.httpClientLayer !== undefined; - - // URL autodetection — fan out across a bounded set of plugins that - // declared a `detect` hook. Collect non-null results up to the - // configured cap. Plugin-level detect implementations should - // swallow fetch errors and return null, so one flaky plugin doesn't - // block the whole dispatch. - const detectionConfidenceScore = (confidence: SourceDetectionResult["confidence"]) => - Match.value(confidence).pipe( - Match.when("high", () => 3), - Match.when("medium", () => 2), - Match.when("low", () => 1), - Match.exhaustive, - ); + // ------------------------------------------------------------------ + // OAuth service seam. + // ------------------------------------------------------------------ - const detectSource = (url: string) => - Effect.gen(function* () { - const trimmed = url.trim(); - if (trimmed.length === 0 || trimmed.length > sourceDetectionMaxUrlLength) return []; - const parsed = yield* Effect.try({ - try: () => new URL(trimmed), - catch: (error) => error, - }).pipe(Effect.option); - if (Option.isNone(parsed)) return []; - if (parsed.value.protocol !== "http:" && parsed.value.protocol !== "https:") return []; - if (sourceDetectionHostedOutboundPolicy) { - const allowed = yield* validateHostedOutboundUrl(trimmed).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - if (!allowed) return []; - } + const oauth = makeOAuthService({ + fuma, + owner: ownerBinding, + tenant, + subject, + ownedKeys: (owner: Owner) => ownedKeys(owner), + defaultWritableProvider, + mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input), + // Resolve the integration's DECLARED oauth scopes for a (integration, + // template): load the row, run the owning plugin's auth-method projector, + // and return the matching oauth method's declared `oauth.scopes`. Drives + // the union-with-client-scopes request in `oauth.start` so reusing a narrow + // client on a broad integration still requests the integration's full + // scope set. Empty (no row / no oauth method / no declared scopes) ⇒ the + // union collapses to the client's scopes (current behavior). + resolveDeclaredOAuthScopes: (integration: IntegrationSlug, template: AuthTemplateSlug) => + findIntegrationRow(integration).pipe( + Effect.map((row): readonly string[] => { + if (!row) return []; + const methods = describeAuthMethodsForRow(row); + const match = + methods.find( + (m: AuthMethodDescriptor) => m.kind === "oauth" && m.template === String(template), + ) ?? methods.find((m: AuthMethodDescriptor) => m.kind === "oauth"); + return match?.oauth?.scopes ?? []; + }), + ), + httpClientLayer: config.httpClientLayer, + endpointUrlPolicy: config.oauthEndpointUrlPolicy, + // EXPLICIT — no localhost default. When a caller omits `redirectUri` the + // OAuth service receives `null` and redirect-requiring flows fail loudly + // instead of silently using `http://127.0.0.1/callback`. Hosts that serve + // OAuth (cloud, self-host) derive a real `${webBaseUrl}/oauth/callback`. + redirectUri: config.redirectUri ?? null, + }); - const results: SourceDetectionResult[] = []; - let detectorCount = 0; - for (const runtime of runtimes.values()) { - if (!runtime.plugin.detect) continue; - if (detectorCount >= sourceDetectionMaxDetectors) break; - detectorCount++; - const result = yield* runtime.plugin - .detect({ ctx: runtime.ctx, url: trimmed }) - .pipe(Effect.timeout(sourceDetectionTimeout)) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (result) results.push(result); - } - return results - .sort( - (a, b) => - detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence), - ) - .slice(0, sourceDetectionMaxResults); - }); + // ------------------------------------------------------------------ + // Plugin wiring — build ctx, run extension, populate static pools, + // register credential providers. + // ------------------------------------------------------------------ - // Per-source definitions accessor — one query, one mapping pass. - const sourceDefinitions = (sourceId: string) => loadDefinitionsForSource(sourceId); + const blobPartitions: OwnerPartitions = { + org: `o:${tenant}`, + user: subject != null ? `u:${tenant}:${subject}` : null, + }; - // Existence check for user-facing secret pickers. Core `secret` - // rows are routing metadata; when a provider can answer `has()`, - // confirm the backing value still exists. Providers without `has()` - // remain conservative so keychain/1password don't need to return - // the value or prompt just to populate picker/status UI. - const secretsStatus = (id: string): Effect.Effect<"resolved" | "missing", StorageFailure> => - Effect.gen(function* () { - const rows = yield* secretRowsForId(id); - // Connection-owned rows are managed through their connection, not the - // picker — skip them (as `secretsList` does) rather than letting one - // poison the whole status. A co-existing org-default value still - // resolves the secret. - for (const row of rows) { - if (row.owned_by_connection_id) continue; - if (yield* secretRouteHasBackingValue(row)) return "resolved"; - } + for (const plugin of plugins) { + if (runtimes.has(plugin.id)) { + return yield* new StorageError({ + message: `Duplicate plugin id: ${plugin.id}`, + cause: undefined, + }); + } - return "missing"; + const pluginStorage = makePluginStorageFacade({ + core, + pluginId: plugin.id, + owner: ownerBinding, }); + const storageDeps: StorageDeps = { + owner: ownerBinding, + blobs: pluginBlobStore(blobs, blobPartitions, plugin.id), + pluginStorage, + }; + const storage = plugin.storage(storageDeps); - // ------------------------------------------------------------------ - // Policies — CRUD surface backed by the `tool_policy` core table. - // The cloud settings UI is one consumer; plugins call the same API - // when they programmatically manage policies. - // - // `list` orders rows innermost scope first, then position ascending. - // Resolution then takes the first local match per scope and applies - // the most restrictive action across scopes. - // ------------------------------------------------------------------ - const policiesList = () => - Effect.gen(function* () { - const rows = yield* loadAllPolicies(); - const sorted = [...rows].sort((a, b) => { - const sa = scopeRank(a); - const sb = scopeRank(b); - if (sa !== sb) return sa - sb; - return comparePolicyRow(a, b); - }); - return sorted.map((row) => rowToToolPolicy(row)); - }).pipe(Effect.withSpan("executor.policies.list")); + const ctx: PluginCtx = { + owner: ownerBinding, + storage, + pluginStorage, + httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer, + core: { + integrations: { + register: (input: RegisterIntegrationInput) => integrationsRegister(plugin.id, input), + update: (slug, patch) => integrationsUpdate(slug, patch), + list: () => integrationsList(), + get: (slug) => integrationsGetRecord(slug), + remove: (slug) => integrationsRemove(slug), + detect: (url) => integrationsDetect(url), + configureSchemas: (): readonly IntegrationConfigureSchema[] => + Array.from(runtimes.values()) + .map(({ plugin }) => + plugin.integrationConfigure + ? { + pluginId: plugin.id, + type: plugin.integrationConfigure.type, + schema: undefined, + } + : undefined, + ) + .filter(Predicate.isNotUndefined), + presets: (): readonly IntegrationPresetCatalogEntry[] => + Array.from(runtimes.values()).flatMap(({ plugin }) => + (plugin.integrationPresets ?? []).map((preset) => ({ + ...preset, + pluginId: plugin.id, + })), + ), + }, + policies: { + list: () => policiesList(), + create: (input) => policiesCreate(input), + update: (input) => policiesUpdate(input), + remove: (input) => policiesRemove(input), + }, + }, + connections: { + create: (input) => connectionsCreate(input), + list: (filter) => connectionsList(filter), + get: (ref) => connectionsGet(ref), + remove: (ref) => connectionsRemove(ref), + refresh: (ref) => connectionsRefresh(ref), + resolveValue: (ref) => resolveConnectionValueByRef(ref), + }, + providers: { + list: () => providersList(), + items: (key) => providersItems(key), + }, + oauth, + transaction: (effect: Effect.Effect) => transaction(effect), + }; - const policiesCreate = (input: CreateToolPolicyInput) => - Effect.gen(function* () { - yield* assertScopeInStack("tool policy targetScope", input.targetScope); - if (!isValidPattern(input.pattern)) { - return yield* new StorageError({ - message: - `Invalid tool policy pattern "${input.pattern}". ` + - `Patterns must be "*" (every tool), an exact tool id ("a.b.c"), ` + - `or a trailing wildcard ("a.b.*"). Leading "*" prefixes ` + - `("*foo", "*.foo") and "**" are not supported.`, - cause: undefined, - }); - } - if (!isToolPolicyAction(input.action)) { - return yield* new StorageError({ - message: - `Invalid tool policy action "${String(input.action)}". ` + - `Expected "approve" | "require_approval" | "block".`, - cause: undefined, - }); - } + // Build extension FIRST so it's available as `self` for staticSources. + const extension: object = plugin.extension ? plugin.extension(ctx) : {}; + if (plugin.extension) { + extensions[plugin.id] = extension; + } - // Default position: a fractional-indexing key above the - // current minimum. Lets newly-created rules win against - // existing ones, which matches the v1 design — users typically - // add a rule to override behavior they're seeing right now, - // not as a background fallback. - let position = input.position; - if (position === undefined) { - const existing = yield* core.findMany("tool_policy", { - where: (b) => b("scope_id", "=", input.targetScope), - }); - let min: string | null = null; - for (const row of existing) { - const p = row.position; - if (min === null || p < min) min = p; + const decls = plugin.staticSources ? plugin.staticSources(extension) : []; + for (const source of decls) { + const mountUnderExecutor = source.kind === "executor"; + const mountedSource = mountUnderExecutor ? EXECUTOR_SOURCE : source; + for (const tool of source.tools) { + const mountedTool = mountUnderExecutor + ? { ...tool, name: `${source.id}.${tool.name}` } + : tool; + const fqid = `${mountedSource.id}.${mountedTool.name}`; + if (staticTools.has(fqid)) { + return yield* new StorageError({ + message: `Duplicate static tool id: ${fqid} (plugin ${plugin.id})`, + cause: undefined, + }); } - position = generateKeyBetween(null, min); - } - - const id = `pol_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`; - const now = new Date(); - yield* core.create("tool_policy", { - id, - scope_id: input.targetScope, - pattern: input.pattern, - action: input.action, - position, - created_at: now, - updated_at: now, - }); - return rowToToolPolicy({ - id, - scope_id: input.targetScope, - pattern: input.pattern, - action: input.action, - position, - created_at: now, - updated_at: now, - } as ToolPolicyRow); - }).pipe(Effect.withSpan("executor.policies.create")); - - const policiesUpdate = (input: UpdateToolPolicyInput) => - Effect.gen(function* () { - yield* assertScopeInStack("tool policy targetScope", input.targetScope); - if (input.pattern !== undefined && !isValidPattern(input.pattern)) { - return yield* new StorageError({ - message: `Invalid tool policy pattern "${input.pattern}".`, - cause: undefined, - }); - } - if (input.action !== undefined && !isToolPolicyAction(input.action)) { - return yield* new StorageError({ - message: `Invalid tool policy action "${String(input.action)}".`, - cause: undefined, - }); - } - - const rows = yield* core.findMany("tool_policy", { - where: byScopedId(input.targetScope, input.id), - }); - const row = rows[0] ?? null; - if (!row) { - return yield* new StorageError({ - message: `Tool policy "${input.id}" not found in scope "${input.targetScope}".`, - cause: undefined, + staticTools.set(fqid, { + source: mountedSource, + tool: mountedTool, + pluginId: plugin.id, + ctx, }); } + } - const updated: ToolPolicyRow = { - ...row, - pattern: input.pattern ?? row.pattern, - action: input.action ?? row.action, - position: input.position ?? row.position, - updated_at: new Date(), - }; - yield* core.updateMany("tool_policy", { - where: byScopedId(input.targetScope, input.id), - set: { - pattern: updated.pattern, - action: updated.action, - position: updated.position, - updated_at: updated.updated_at, - }, - }); - return rowToToolPolicy(updated); - }).pipe(Effect.withSpan("executor.policies.update")); + runtimes.set(plugin.id, { plugin, storage, ctx }); - const policiesRemove = (input: RemoveToolPolicyInput) => - Effect.gen(function* () { - yield* assertScopeInStack("tool policy targetScope", input.targetScope); - yield* core.deleteMany("tool_policy", { - where: byScopedId(input.targetScope, input.id), - }); - }).pipe(Effect.withSpan("executor.policies.remove")); + if (plugin.credentialProviders) { + const raw = + typeof plugin.credentialProviders === "function" + ? plugin.credentialProviders(ctx) + : plugin.credentialProviders; + const providers = Effect.isEffect(raw) + ? yield* raw.pipe( + Effect.mapError((cause) => + pluginStorageFailure(plugin.id, "credentialProviders", cause), + ), + ) + : raw; + for (const provider of providers) { + yield* registerCredentialProvider(provider, `plugin ${plugin.id}`); + } + } + } - const policiesResolve = (toolId: string) => - resolveToolPolicyForId(toolId).pipe(Effect.withSpan("executor.policies.resolve")); + // ------------------------------------------------------------------ + // close + // ------------------------------------------------------------------ const close = () => Effect.gen(function* () { @@ -4464,60 +3034,30 @@ export const createExecutor = Effect.sync(() => Array.from(secretProviders.keys()) as readonly string[]), + integrations: { + list: integrationsList, + get: integrationsGet, + update: integrationsUpdatePublic, + remove: integrationsRemove, + detect: integrationsDetect, }, connections: { - get: connectionsGet, - getAtScope: connectionsGetAtScope, - list: connectionsList, create: connectionsCreate, - updateTokens: connectionsUpdateTokens, - setIdentityLabel: connectionsSetIdentityLabel, - setIdentityOverride: connectionsSetIdentityOverride, - accessToken: connectionsAccessToken, - accessTokenAtScope: connectionsAccessTokenAtScope, + list: connectionsList, + get: connectionsGet, remove: connectionsRemove, - usages: connectionsUsages, - providers: () => - Effect.sync(() => Array.from(connectionProviders.keys()) as readonly string[]), + refresh: connectionsRefresh, + }, + oauth, + tools: { + list: toolsList, + schema: toolSchema, + }, + providers: { + list: providersList, + items: providersItems, }, - credentialBindings, - oauth: oauthBundle.service, policies: { list: policiesList, create: policiesCreate, @@ -4525,11 +3065,14 @@ export const createExecutor = => value as Executor; return toExecutor(Object.assign(base, extensions)); }); + +// Helper alias so the inline literal used for the optimistic projection in +// `produceConnectionTools` satisfies the ToolRow shape. +type ConnectionToolRow = ToolRow; diff --git a/packages/core/sdk/src/http-source.ts b/packages/core/sdk/src/http-source.ts deleted file mode 100644 index c85f4206a..000000000 --- a/packages/core/sdk/src/http-source.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Effect, Schema } from "effect"; - -import { - ConnectionId, - ConfiguredCredentialBinding, - credentialSlotPart, - type ConfiguredCredentialValue, - type CredentialBindingValue, - type ScopedSecretCredentialInput, - SecretId, - ScopeId, -} from "./shared"; - -export const HttpCredentialInput = Schema.Union([ - Schema.String, - Schema.Struct({ - kind: Schema.Literal("text"), - text: Schema.String, - prefix: Schema.optional(Schema.String), - }), - Schema.Struct({ - kind: Schema.Literal("secret"), - secretId: Schema.String, - secretScope: Schema.optional(Schema.String), - prefix: Schema.optional(Schema.String), - }), - Schema.Struct({ - kind: Schema.Literal("connection"), - connectionId: Schema.String, - }), -]); -export type HttpCredentialInput = typeof HttpCredentialInput.Type; -export type HttpCredentialInputType = HttpCredentialInput; - -export const HttpConfiguredValueInput = Schema.Union([ - Schema.String, - Schema.Struct({ - kind: Schema.Literal("secret"), - prefix: Schema.optional(Schema.String), - }), -]); -export type HttpConfiguredValueInput = typeof HttpConfiguredValueInput.Type; -export type HttpConfiguredValueInputType = HttpConfiguredValueInput; - -export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentials"]); -export type OAuth2Flow = typeof OAuth2Flow.Type; -export type OAuth2FlowType = OAuth2Flow; - -export const OAuth2IdentityScopes = Schema.Union([ - Schema.Literal("auto"), - Schema.Literal(false), - Schema.Array(Schema.String), -]); -export type OAuth2IdentityScopes = typeof OAuth2IdentityScopes.Type; -export type OAuth2IdentityScopesType = OAuth2IdentityScopes; - -export const OAuth2SourceConfig = Schema.Struct({ - kind: Schema.Literal("oauth2"), - securitySchemeName: Schema.String, - flow: OAuth2Flow, - tokenUrl: Schema.String, - authorizationUrl: Schema.NullOr(Schema.String).pipe( - Schema.optional, - Schema.withDecodingDefault(Effect.succeed(null)), - ), - issuerUrl: Schema.optional(Schema.NullOr(Schema.String)), - clientIdSlot: Schema.String, - clientSecretSlot: Schema.NullOr(Schema.String), - connectionSlot: Schema.String, - scopes: Schema.Array(Schema.String), - identityScopes: OAuth2IdentityScopes.pipe( - Schema.optional, - Schema.withDecodingDefault(Effect.succeed("auto" as const)), - ), -}).annotate({ identifier: "OAuth2SourceConfig" }); -export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type; -export type OAuth2SourceConfigType = OAuth2SourceConfig; - -export const HttpOAuthConfigureInput = Schema.Struct({ - clientId: Schema.optional(HttpCredentialInput), - clientSecret: Schema.optional(Schema.NullOr(HttpCredentialInput)), - connection: Schema.optional(HttpCredentialInput), -}).annotate({ identifier: "HttpOAuthConfigureInput" }); -export type HttpOAuthConfigureInput = typeof HttpOAuthConfigureInput.Type; -export type HttpOAuthConfigureInputType = HttpOAuthConfigureInput; - -export type HttpCredentialSection = "request" | "specFetch" | "introspection"; -export type HttpCredentialPlacement = "headers" | "query"; - -export const httpCredentialSlotKey = ( - section: HttpCredentialSection, - placement: HttpCredentialPlacement, - name: string, -): string => `${section}.${placement}.${credentialSlotPart(name)}`; - -export const httpHeaderSlotKey = (section: HttpCredentialSection, name: string): string => - httpCredentialSlotKey(section, "headers", name); - -export const httpQuerySlotKey = (section: HttpCredentialSection, name: string): string => - httpCredentialSlotKey(section, "query", name); - -export const httpOAuthConnectionSlotKey = (section: HttpCredentialSection): string => - `${section}.oauth.connection`; - -export const httpOAuthClientIdSlotKey = (section: HttpCredentialSection): string => - `${section}.oauth.clientId`; - -export const httpOAuthClientSecretSlotKey = (section: HttpCredentialSection): string => - `${section}.oauth.clientSecret`; - -export const httpSectionSlotPrefix = (section: HttpCredentialSection): string => `${section}.`; - -export type HttpNamedCredentialInput = - | ConfiguredCredentialValue - | ScopedSecretCredentialInput - | { - readonly secretId: string; - readonly prefix?: string; - readonly targetScope?: string; - readonly secretScopeId?: string; - }; - -export interface CompiledHttpNamedCredentialBinding { - readonly slot: string; - readonly value: CredentialBindingValue; - readonly targetScope?: string; -} - -export const compileHttpNamedCredentialMap = ( - values: Record | undefined, - slotForName: (name: string) => string, -): { - readonly values: Record; - readonly bindings: readonly CompiledHttpNamedCredentialBinding[]; -} => { - const nextValues: Record = {}; - const bindings: CompiledHttpNamedCredentialBinding[] = []; - for (const [name, value] of Object.entries(values ?? {})) { - if (typeof value === "string") { - nextValues[name] = value; - continue; - } - if ("kind" in value) { - if (value.kind === "binding") { - nextValues[name] = value; - continue; - } - const slot = slotForName(name); - nextValues[name] = ConfiguredCredentialBinding.make({ - kind: "binding", - slot, - prefix: "prefix" in value ? value.prefix : undefined, - }); - bindings.push({ - slot, - value: httpCredentialInputToBindingValue(value), - }); - continue; - } - const slot = slotForName(name); - nextValues[name] = ConfiguredCredentialBinding.make({ - kind: "binding", - slot, - prefix: value.prefix, - }); - bindings.push({ - slot, - targetScope: "targetScope" in value ? value.targetScope : undefined, - value: { - kind: "secret", - secretId: SecretId.make(value.secretId), - ...("secretScopeId" in value && value.secretScopeId - ? { secretScopeId: ScopeId.make(value.secretScopeId) } - : {}), - }, - }); - } - return { values: nextValues, bindings }; -}; - -export const httpCredentialInputToBindingValue = ( - input: HttpCredentialInput, -): CredentialBindingValue => { - if (typeof input === "string") { - return { - kind: "text", - text: input, - }; - } - if (input.kind === "text") { - return { - kind: "text", - text: input.text, - }; - } - if (input.kind === "secret") { - return { - kind: "secret", - secretId: SecretId.make(input.secretId), - ...(input.secretScope ? { secretScopeId: ScopeId.make(input.secretScope) } : {}), - }; - } - if (input.kind === "connection") { - return { - kind: "connection", - connectionId: ConnectionId.make(input.connectionId), - }; - } - return input; -}; diff --git a/packages/core/sdk/src/ids.ts b/packages/core/sdk/src/ids.ts index decba9f13..9befce330 100644 --- a/packages/core/sdk/src/ids.ts +++ b/packages/core/sdk/src/ids.ts @@ -1,19 +1,80 @@ import { Schema } from "effect"; -export const ScopeId = Schema.String.pipe(Schema.brand("ScopeId")); -export type ScopeId = typeof ScopeId.Type; +/* Branded identifiers. Schema brands (not plain `Brand.nominal`) so they're + * usable both as types and as fields inside Schema structs/errors. Construct + * with `X.make("…")`. */ -export const ToolId = Schema.String.pipe(Schema.brand("ToolId")); -export type ToolId = typeof ToolId.Type; +/** An integration's catalog slug — one API surface (e.g. "vercel", "google"). */ +export const IntegrationSlug = Schema.String.pipe(Schema.brand("IntegrationSlug")); +export type IntegrationSlug = typeof IntegrationSlug.Type; -export const SecretId = Schema.String.pipe(Schema.brand("SecretId")); -export type SecretId = typeof SecretId.Type; +/** Which of an integration's declared auth methods a connection applies through. */ +export const AuthTemplateSlug = Schema.String.pipe(Schema.brand("AuthTemplateSlug")); +export type AuthTemplateSlug = typeof AuthTemplateSlug.Type; +/** The sentinel template for integrations that require no credential (e.g. a + * public MCP server). Connections on it legitimately bind zero inputs — an + * empty `item_ids` map is their canonical persisted shape. */ +export const NO_AUTH_TEMPLATE = AuthTemplateSlug.make("none"); + +/** A connection's name — the `` segment of an address, scoped under + * its integration + owner (so the same name can exist on two integrations). */ +export const ConnectionName = Schema.String.pipe(Schema.brand("ConnectionName")); +export type ConnectionName = typeof ConnectionName.Type; + +/** A registered OAuth app's slug. */ +export const OAuthClientSlug = Schema.String.pipe(Schema.brand("OAuthClientSlug")); +export type OAuthClientSlug = typeof OAuthClientSlug.Type; + +/** OAuth flow correlation token, minted by `start`, consumed by `complete`. */ +export const OAuthState = Schema.String.pipe(Schema.brand("OAuthState")); +export type OAuthState = typeof OAuthState.Type; + +/** A credential backend's key (e.g. "default", "1password", "keychain"). */ +export const ProviderKey = Schema.String.pipe(Schema.brand("ProviderKey")); +export type ProviderKey = typeof ProviderKey.Type; + +/** A provider's own opaque handle for a stored value. Core never parses it. */ +export const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId")); +export type ProviderItemId = typeof ProviderItemId.Type; + +/** Handle for one connection: `tools...`. */ +export const ConnectionAddress = Schema.String.pipe(Schema.brand("ConnectionAddress")); +export type ConnectionAddress = typeof ConnectionAddress.Type; + +/** Full callable tool address: `.` = + * `tools....`. */ +export const ToolAddress = Schema.String.pipe(Schema.brand("ToolAddress")); +export type ToolAddress = typeof ToolAddress.Type; + +/** Final address segment — a tool's own name. */ +export const ToolName = Schema.String.pipe(Schema.brand("ToolName")); +export type ToolName = typeof ToolName.Type; + +/** Correlation id for a URL elicitation callback. */ +export const ElicitationId = Schema.String.pipe(Schema.brand("ElicitationId")); +export type ElicitationId = typeof ElicitationId.Type; + +/** A tool-policy rule id. */ export const PolicyId = Schema.String.pipe(Schema.brand("PolicyId")); export type PolicyId = typeof PolicyId.Type; -export const ConnectionId = Schema.String.pipe(Schema.brand("ConnectionId")); -export type ConnectionId = typeof ConnectionId.Type; +/** + * The isolation partition (the org/workspace). Owns the catalog and namespaces + * every connection. The executor is bound to one; `owner: "org"` files at this + * level. Opaque to the SDK. + */ +export const Tenant = Schema.String.pipe(Schema.brand("Tenant")); +export type Tenant = typeof Tenant.Type; + +/** The acting member identity. Required for `owner: "user"` writes. Opaque. */ +export const Subject = Schema.String.pipe(Schema.brand("Subject")); +export type Subject = typeof Subject.Type; -export const CredentialBindingId = Schema.String.pipe(Schema.brand("CredentialBindingId")); -export type CredentialBindingId = typeof CredentialBindingId.Type; +/** + * Who owns a connection: the org (tenant-shared, everyone uses it) or the acting + * user (this subject's own). The `` segment of an address. Maps onto the + * executor's tenant/subject binding; the SDK never interprets further. + */ +export const Owner = Schema.Literals(["org", "user"]); +export type Owner = typeof Owner.Type; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 2787f1b7c..a6ea8597e 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -1,11 +1,9 @@ // --------------------------------------------------------------------------- -// @executor-js/sdk — public surface +// @executor-js/sdk — public surface (v2) // --------------------------------------------------------------------------- // Re-export the Effect/Schema/HttpApi primitives plugin authors need so a -// plugin can be written importing only from `@executor-js/sdk`. Authors who -// want to reach for additional Effect APIs keep importing from `effect/*` -// directly — these re-exports are the curated minimum. +// plugin can be written importing only from `@executor-js/sdk`. export { Context, Effect, Layer, Schema, Data, Option } from "effect"; export { HttpApi, @@ -34,88 +32,106 @@ export type { } from "./fuma-runtime"; export { StorageError, UniqueViolationError, isStorageFailure } from "./fuma-runtime"; -// Storage-layer typed errors are still exported so plugin code can catchTag -// `UniqueViolationError`, but FumaDB itself is the storage API. - -// IDs (branded) -export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId, CredentialBindingId } from "./ids"; - -// Scope +// IDs (branded) — the v2 set. export { - Scope, - defaultSourceInstallScopeId, - userOrgScopeId, - parseUserOrgScopeId, - makeUserOrgScopeStack, -} from "./scope"; + IntegrationSlug, + AuthTemplateSlug, + ConnectionName, + OAuthClientSlug, + OAuthState, + ProviderKey, + ProviderItemId, + ConnectionAddress, + ToolAddress, + ToolName, + ElicitationId, + PolicyId, + Tenant, + Subject, + Owner, +} from "./ids"; +export { connectionIdentifier, isConnectionIdentifier } from "./connection-name-identifier"; -// Errors (tagged) +// Errors (tagged) — the ExecuteError set + integration lifecycle. export { ToolNotFoundError, ToolInvocationError, ToolBlockedError, NoHandlerError, - SourceNotFoundError, - SourceRemovalNotAllowedError, PluginNotLoadedError, - SecretNotFoundError, - SecretResolutionError, - SecretOwnedByConnectionError, - SecretInUseError, + IntegrationNotFoundError, + IntegrationAlreadyExistsError, + IntegrationRemovalNotAllowedError, ConnectionNotFoundError, - ConnectionProviderNotRegisteredError, - ConnectionRefreshNotSupportedError, - ConnectionReauthRequiredError, - ConnectionInUseError, + CredentialProviderNotRegisteredError, + CredentialResolutionError, + type ExecuteError, type ExecutorError, } from "./errors"; -// Public projections -export { - ToolSchemaView, - SourceDetectionResult, - type RefreshSourceInput, - type RemoveSourceInput, - type Source, - type ToolView, - type ToolListFilter, -} from "./types"; +// Integration / connection / tool domain contracts. +export type { + AuthMethodDescriptor, + AuthMethodOAuthDescriptor, + AuthPlacementDescriptor, + Integration, + IntegrationConfig, + IntegrationDisplayDescriptor, + RegisterIntegrationInput, +} from "./integration"; +export { freshCustomAuthSlug, mergeAuthTemplates } from "./integration"; +export type { + Connection, + ConnectionRef, + ConnectionValueInput, + CreateConnectionInput, +} from "./connection"; +export type { Tool, ToolDef, ToolListFilter, ToolAnnotations } from "./tool"; + +// Credential providers. +export type { CredentialProvider, ProviderEntry } from "./provider"; + +// Public projections / detection. +export { ToolSchemaView, IntegrationDetectionResult } from "./types"; -// Core schema +// Core schema. export { bigintColumn, boolColumn, coreSchema, + coreTables, dateColumn, isToolPolicyAction, jsonColumn, + keyColumn, nullableBigintColumn, nullableJsonColumn, + nullableKeyColumn, nullableTextColumn, - scopedExecutorTable, textColumn, TOOL_POLICY_ACTIONS, type CoreSchema, - type SourceInput, - type SourceInputTool, - type SourceRow, + type IntegrationRow, + type ConnectionRow, + type OAuthClientRow, + type OAuthSessionRow, type ToolRow, type DefinitionRow, - type SecretRow, - type ConnectionRow, - type PluginStorageRow, - type CredentialBindingRow, type ToolPolicyRow, + type PluginStorageRow, + type BlobRow, type ToolPolicyAction, - type DefinitionsInput, - type ToolAnnotations, } from "./core-schema"; -// Tool policies. `matchPattern`/`isValidPattern` are consumed by the React UI; -// `effectivePolicyFromSorted` + `ToolPolicyActionSchema` are shared contracts. -// `resolveToolPolicy`/`resolveEffectivePolicy`/`rowToToolPolicy` are off the -// barrel: they are SDK-internal (used inside `createExecutor`), not a plugin or -// consumer contract. +// Owner policy. +export { + ORG_SUBJECT, + executorOwnerPolicyName, + executorUnscopedPolicyName, + type ExecutorOwnerPolicyContext, +} from "./owner-policy"; + +// Tool policies. export { matchPattern, isValidPattern, @@ -130,64 +146,7 @@ export { type PolicySource, } from "./policies"; -// Secrets -export { SecretRef, SetSecretInput, RemoveSecretInput, type SecretProvider } from "./secrets"; - -export { - SecretBackedMap, - SecretBackedValue, - isSecretBackedRef, - resolveSecretBackedMap, - type ResolveSecretBackedMapOptions, -} from "./secret-backed-value"; - -export { - CredentialBindingKind, - CredentialBindingValue, - ConfiguredCredentialBinding, - ConfiguredCredentialValue, - ScopedSecretCredentialInput, - CredentialBindingRef, - CredentialBindingSlotInput, - RemoveCredentialBindingInput, - RemoveSourceCredentialBindingInput, - ReplaceCredentialBindingValue, - ReplaceCredentialBindingsInput, - ReplaceSourceCredentialBindingsInput, - CredentialBindingResolutionStatus, - ResolvedCredentialSlot, - SetSourceCredentialBindingInput, - SourceCredentialBindingSource, - SourceCredentialBindingSourceInput, - SourceCredentialBindingSlotInput, - credentialBindingId, - credentialSlotKey, - credentialSlotPart, - credentialBindingRowToRef, - credentialBindingValueFromRow, - type CredentialBindingsFacade, -} from "./credential-bindings"; - -// Usage tracking — secret/connection refs across plugins -export { Usage, type UsagesForSecretInput, type UsagesForConnectionInput } from "./usages"; - -// Connections -export { - ConnectionRef, - ConnectionIdentityOverride, - ConnectionProviderState, - CreateConnectionInput, - RemoveConnectionInput, - UpdateConnectionIdentityInput, - UpdateConnectionTokensInput, - TokenMaterial, - ConnectionRefreshError, - type ConnectionProvider, - type ConnectionRefreshInput, - type ConnectionRefreshResult, -} from "./connections"; - -// Elicitation +// Elicitation. export { FormElicitation, UrlElicitation, @@ -197,14 +156,22 @@ export { type ElicitationRequest, type ElicitationHandler, type ElicitationContext, + type OnElicitation, + type InvokeOptions, } from "./elicitation"; // Blob store — the plugin-facing CONTRACT only. The concrete makers -// (`makeFumaBlobStore`/`makeInMemoryBlobStore`) are SDK-internal: `createExecutor` -// wires the blob store, plugins only ever receive a `PluginBlobStore`. -export { type BlobStore, type PluginBlobStore, pluginBlobStore } from "./blob"; +// (`makeFumaBlobStore`/`makeInMemoryBlobStore`) are SDK-internal. +export { + pluginBlobStore, + makeInMemoryBlobStore, + makeFumaBlobStore, + type BlobStore, + type PluginBlobStore, + type OwnerPartitions, +} from "./blob"; -// Plugin storage +// Plugin storage. export { definePluginStorageCollection, pluginStorageId, @@ -235,40 +202,32 @@ export { type PluginStorageWhereValue, } from "./plugin-storage"; -// OAuth 2.1 +// OAuth (v2 contracts). +export { OAUTH2_PROVIDER_KEY, OAUTH2_SESSION_TTL_MS } from "./oauth"; export { - type OAuthService, - type OAuthStrategy, - type OAuthDynamicDcrStrategy, - type OAuthAuthorizationCodeStrategy, - type OAuthClientCredentialsStrategy, - type OAuthProviderState, - type OAuthProbeInput, - type OAuthProbeResult, - type OAuthStartInput, - type OAuthStartResult, - type OAuthCompleteInput, - type OAuthCompleteResult, - OAuthProbeError, OAuthStartError, OAuthCompleteError, + OAuthProbeError, + OAuthRegisterDynamicError, OAuthSessionNotFoundError, - OAUTH2_PROVIDER_KEY, - OAUTH2_SESSION_TTL_MS, - OAuthStrategy as OAuthStrategySchema, - OAuthProviderState as OAuthProviderStateSchema, - OAuthDynamicDcrStrategy as OAuthDynamicDcrStrategySchema, - OAuthAuthorizationCodeStrategy as OAuthAuthorizationCodeStrategySchema, - OAuthClientCredentialsStrategy as OAuthClientCredentialsStrategySchema, -} from "./oauth"; + type OAuthGrant, + type OAuthAuthentication, + type OAuthClient, + type OAuthClientSummary, + type CreateOAuthClientInput, + type RegisterDynamicClientInput, + type ConnectResult, + type OAuthStartInput, + type OAuthCompleteInput, + type OAuthProbeInput, + type OAuthProbeResult, + type OAuthService, +} from "./oauth-client"; -// NOTE: the OAuth 2.1 implementation helpers (PKCE/exchange/refresh in -// `./oauth-helpers`, `makeOAuth2Service` in `./oauth-service`, and the dynamic -// discovery/registration in `./oauth-discovery`) are SDK-internal: they are -// consumed only by `createExecutor`'s built-in OAuth flow, never by plugins. -// The plugin-facing OAuth CONTRACTS (the schemas/types + `OAUTH2_PROVIDER_KEY`) -// stay exported above. The hosted HTTP client builder is host-internal too and -// reachable via `@executor-js/sdk/host-internal`. +// NOTE: the OAuth 2.1 implementation helpers (`./oauth-helpers`, +// `makeOAuthService` in `./oauth-service`, discovery in `./oauth-discovery`) +// are SDK-internal — consumed only by `createExecutor`. The hosted HTTP client +// builder is host-internal and reachable via `@executor-js/sdk/host-internal`. export { DEFAULT_EXECUTOR_SERVER_ORIGIN, @@ -290,7 +249,7 @@ export { isOAuthPopupResult, } from "./oauth-popup-types"; -// Plugin definition +// Plugin definition. export { type Plugin, type PluginSpec, @@ -299,69 +258,62 @@ export { type ConfiguredPlugin, type AnyPlugin, type StorageDeps, + type OwnerBinding, + type IntegrationRecord, type StaticSourceDecl, type StaticToolDecl, type StaticToolSchema, type StaticToolExecuteContext, type StaticToolHandlerInput, type StaticToolInput, - type ConfigureSourceHandlerInput, + type ConfigureIntegrationHandlerInput, type InvokeToolInput, - type SourceLifecycleInput, - type SourceConfigureDecl, - type SecretListEntry, + type ConnectionLifecycleInput, + type IntegrationConfigureDecl, + type IntegrationConfigureSchema, + type IntegrationPreset, + type IntegrationPresetCatalogEntry, + type ResolveToolsInput, + type ResolveToolsResult, + type ToolInvocationCredential, type Elicit, definePlugin, tool, } from "./plugin"; -// Executor +// Executor. // // `collectTables` is host/tooling-only (cli schema cmd, kernel worker, // local/cloud DB bring-up). Its definition stays here because `createExecutor` -// uses it; the host surface (`@executor-js/api/server`) re-exports it so hosts -// import it alongside the other host-composition seams. The CLI + kernel -// tooling, which only depend on `@executor-js/sdk` (not `@executor-js/api`), -// keep importing it from here. +// uses it; the host surface (`@executor-js/api/server`) re-exports it. export { type Executor, type ExecutorConfig, type ExecutorDb, type ExecutorDbFactory, type ExecutorDbInput, - type OnElicitation, - type InvokeOptions, + type ParsedToolAddress, createExecutor, collectTables, + parseToolAddress, + connectionAddress, + toolAddress, } from "./executor"; -// NOTE: the host-composition seams (`DbProvider`/`dbProviderLayer`, -// `makeScopedExecutor`/`HostConfig`/`PluginsProvider`, `createExecutorFumaDb`) -// are NOT on this plugin-author barrel — they live in the host surface -// (`@executor-js/api/server`). The pure FumaDB assembly stays in the SDK for the -// sqlite test backend and is exposed to the host layer via -// `@executor-js/sdk/host-internal`. - -// CLI / runtime config +// CLI / runtime config. export { defineExecutorConfig, type ExecutorCliConfig, type ExecutorPluginsFactory, } from "./config"; -// NOTE: the JSON-schema `$ref` helpers (`./schema-refs`) and most TypeScript -// preview generators (`./schema-types`) are SDK-internal — `./schema-types` -// consumes `./schema-refs` and is used inside `createExecutor`. The one -// exception is `buildToolTypeScriptPreview`: plugins assert the TS preview of -// their derived tools (the openapi Google-discovery suite), so it is exported. +// The one TS-preview generator plugins assert against. export { buildToolTypeScriptPreview } from "./schema-types"; // Wire-level HTTP error schemas usable by plugin HttpApiGroup definitions. export { InternalError } from "./api-errors"; // ToolResult — typed value-based discriminated union for tool outcomes. -// Distinct from the `ToolView` row projection (`./types`) and the `tool()` -// builder (`./plugin`): one word per concept, three names. export { ToolResult, isToolResult, type ToolError } from "./tool-result"; export { authToolFailure, diff --git a/packages/core/sdk/src/integration.ts b/packages/core/sdk/src/integration.ts new file mode 100644 index 000000000..730e0f4cb --- /dev/null +++ b/packages/core/sdk/src/integration.ts @@ -0,0 +1,146 @@ +import type { IntegrationSlug } from "./ids"; + +/* Core knows only an integration's catalog identity — slug + description + which + * plugin (`kind`) owns it. The type-specific shape (openapi auth templates + spec, + * an mcp url, …) lives in the plugin and is stored as an opaque `config` blob core + * never parses. An integration is one API surface; multi-API providers (Google) + * are bundled into a single integration by their plugin, so one credential covers + * the whole provider. */ + +// --------------------------------------------------------------------------- +// Declared auth methods — a plugin-agnostic projection of an integration's +// stored `config` into the catalog response. Each plugin derives these from its +// own opaque config (`describeAuthMethods`); core never parses config itself. +// The client renders these as the integration's selectable auth methods, so the +// catalog is authoritative even when the integration has zero connections. +// +// This is a DERIVED projection — there is no DB column. A plugin that declares +// no projector contributes `[]`, and the client falls through to its existing +// connection-inference behavior (no regression). +// --------------------------------------------------------------------------- + +export interface IntegrationDisplayDescriptor { + /** Non-secret URL suitable for display metadata such as favicons. */ + readonly url?: string; +} + +/** Where a credential value is carried on the outbound request. Mirrors the + * client's `Placement`. */ +export interface AuthPlacementDescriptor { + readonly carrier: "header" | "query"; + readonly name: string; + /** Literal prepended to the value (e.g. `"Bearer "`). Empty when bare. */ + readonly prefix: string; + /** The input variable this placement renders from. `token` for single-input + * methods; a distinct name per input for multi-input ones (e.g. Datadog). + * Absent → treated as `token`. */ + readonly variable?: string; +} + +/** OAuth specifics for an `oauth` auth method. For probe-at-connect providers + * (MCP) only `discoveryUrl` + `supportsDynamicRegistration` are known up front; + * the authorize/token endpoints are discovered live at connect time. For + * providers that store endpoints (OpenAPI) the resolved URLs are carried. */ +export interface AuthMethodOAuthDescriptor { + /** For probe-at-connect providers (MCP): the endpoint to discover metadata + * from (RFC 9728 PRM → RFC 8414 AS metadata). */ + readonly discoveryUrl?: string; + readonly authorizationUrl?: string; + readonly tokenUrl?: string; + readonly scopes?: readonly string[]; + readonly registrationEndpoint?: string; + /** True when the integration is known to support RFC 7591 dynamic client + * registration (drives the transparent auto-register connect flow). */ + readonly supportsDynamicRegistration?: boolean; +} + +/** A single declared auth method on an integration's catalog response. */ +export interface AuthMethodDescriptor { + /** Stable id within the integration (e.g. the auth template slug). */ + readonly id: string; + readonly label: string; + readonly kind: "oauth" | "apikey" | "header" | "none"; + /** The auth-template slug a connection binds against. */ + readonly template: string; + readonly placements?: readonly AuthPlacementDescriptor[]; + readonly oauth?: AuthMethodOAuthDescriptor; +} + +/** Public projection of an integration — what `integrations.list/get` return. + * Carries no credentials and no plugin-internal config. */ +export interface Integration { + readonly slug: IntegrationSlug; + readonly description: string; + /** The plugin that owns this integration kind (e.g. "openapi", "mcp"). */ + readonly kind: string; + /** Whether the user can remove this integration from the catalog. `false` + * for static / built-in integrations declared by a plugin at startup. */ + readonly canRemove: boolean; + /** Whether the owning plugin supports re-resolving a connection's tools + * (`connections.refresh`). */ + readonly canRefresh: boolean; + /** Declared auth methods derived from the owning plugin's stored config (a + * derived projection, not a DB column). Always present, possibly empty. */ + readonly authMethods: readonly AuthMethodDescriptor[]; + /** Non-secret display URL derived by the owning plugin from opaque config. + * Used for catalog favicons; never includes credentials or plugin config. */ + readonly displayUrl?: string; +} + +/** Plugin-owned, opaque-to-core configuration stored on the integration row. The + * owning plugin writes it at register time and reads it back at execute time to + * render auth / produce tools. Core treats it as an opaque JSON blob. */ +export type IntegrationConfig = unknown; + +// --------------------------------------------------------------------------- +// Auth-template merge — shared by every plugin whose config carries a slugged +// `authenticationTemplate` array (openapi, graphql, mcp). The custom-method +// flow merge-appends: an incoming entry with a matching slug replaces the +// existing entry in place; entries lacking a slug (or colliding with another +// entry added in the same call) get a fresh `custom_` slug. +// --------------------------------------------------------------------------- + +const shortId = (): string => Math.random().toString(36).slice(2, 8); + +export const freshCustomAuthSlug = (taken: ReadonlySet): string => { + let candidate = `custom_${shortId()}`; + while (taken.has(candidate)) candidate = `custom_${shortId()}`; + return candidate; +}; + +export const mergeAuthTemplates = ( + existing: readonly T[], + incoming: readonly T[], +): readonly T[] => { + const result: T[] = existing.map((entry: T) => entry); + const taken = new Set(result.map((entry: T) => String(entry.slug))); + for (const entry of incoming) { + // `slug` may be branded-required in the plugin's schema, but JSON callers + // can submit it empty/blank — read defensively and backfill so every + // stored template has a stable slug. + const rawSlug = (entry as { readonly slug?: unknown }).slug; + const requested = typeof rawSlug === "string" ? rawSlug.trim() : ""; + const existingIndex = result.findIndex((current: T) => String(current.slug) === requested); + if (requested.length > 0 && existingIndex >= 0) { + result[existingIndex] = entry; + continue; + } + const slug = + requested.length > 0 && !taken.has(requested) ? requested : freshCustomAuthSlug(taken); + taken.add(slug); + result.push({ ...entry, slug } as T); + } + return result; +}; + +/** What a plugin's extension method passes to `ctx.core.integrations.register`. + * The v2 analog of v1's `SourceInput`, minus the per-source tool list (tools are + * produced per-connection now). */ +export interface RegisterIntegrationInput { + readonly slug: IntegrationSlug; + readonly description: string; + /** Opaque plugin config (auth templates, spec ref, mcp url, …). */ + readonly config: IntegrationConfig; + readonly canRemove?: boolean; + readonly canRefresh?: boolean; +} diff --git a/packages/core/sdk/src/migration-oauth-metadata.test.ts b/packages/core/sdk/src/migration-oauth-metadata.test.ts new file mode 100644 index 000000000..ab9aa91a8 --- /dev/null +++ b/packages/core/sdk/src/migration-oauth-metadata.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + migrationOAuthAuthorizationUrlFor, + migrationOAuthClientAuthorizationUrlResolutionSource, + migrationOAuthClientNeedsAuthorizationUrlResolution, + migrationOAuthClientPlanKey, + resolveMigrationOAuthAuthorizationUrls, + type MigrationOAuthMetadataFetch, + type MigrationPlan, +} from "./migration-spec"; + +type OAuthClient = MigrationPlan["oauthClients"][number]; + +const ownerKeys = { owner: "user" as const, subject: "user_1", tenant: "org_1" }; + +const oauthClient = (overrides: Partial = {}): OAuthClient => ({ + ownerKeys, + clientId: "client-id", + tokenUrl: "https://oauth.example.com/token", + authorizationUrl: "https://oauth.example.com/authorize", + grant: "authorization_code", + resource: null, + clientSecretRef: null, + slug: "oauth", + clientSecretItemId: null, + ...overrides, +}); + +const migrationPlan = (oauthClients: readonly OAuthClient[]): MigrationPlan => ({ + integrations: [], + oauthClients, + connections: [], + secretOps: [], + policies: [], + report: { + integrations: 0, + oauthClients: oauthClients.length, + connections: 0, + secretOps: 0, + staleConnections: 0, + policies: { ok: 0, static: 0, deadInert: 0 }, + warnings: [], + }, +}); + +const jsonResponse = ( + body: unknown, + status = 200, +): Awaited> => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +const fetchFixture = ( + responses: Readonly>, +): { + readonly fetch: MigrationOAuthMetadataFetch; + readonly seen: readonly string[]; +} => { + const seen: string[] = []; + return { + seen, + fetch: async (input) => { + seen.push(input); + const response = responses[input]; + if (!response) return jsonResponse({ error: "not_found" }, 404); + return jsonResponse(response.body, response.status ?? 200); + }, + }; +}; + +describe("resolveMigrationOAuthAuthorizationUrls", () => { + it("uses an archived authorization-server metadata URL when present", async () => { + const metadataUrl = + "https://mcp.pscale.dev/.well-known/oauth-authorization-server/mcp/planetscale"; + const client = oauthClient({ + slug: "planetscale", + authorizationUrl: "https://mcp.pscale.dev/mcp/planetscale", + authorizationServerMetadataUrl: metadataUrl, + resource: "https://mcp.pscale.dev/mcp/planetscale", + }); + const fixture = fetchFixture({ + [metadataUrl]: { + body: { + authorization_endpoint: "https://app.planetscale.com/oauth/authorize", + }, + }, + }); + + const resolved = await resolveMigrationOAuthAuthorizationUrls(migrationPlan([client]), { + fetch: fixture.fetch, + }); + + expect(fixture.seen).toEqual([metadataUrl]); + expect(resolved.get(migrationOAuthClientPlanKey(client))).toBe( + "https://app.planetscale.com/oauth/authorize", + ); + expect(migrationOAuthAuthorizationUrlFor(client, resolved)).toBe( + "https://app.planetscale.com/oauth/authorize", + ); + }); + + it("discovers an MCP authorization endpoint through protected-resource metadata", async () => { + const client = oauthClient({ + slug: "linear", + authorizationUrl: "https://mcp.linear.example.com", + tokenUrl: "https://mcp.linear.example.com/token", + resource: "https://mcp.linear.example.com/mcp", + }); + const fixture = fetchFixture({ + "https://mcp.linear.example.com/.well-known/oauth-protected-resource/mcp": { + body: { authorization_servers: ["https://mcp.linear.example.com"] }, + }, + "https://mcp.linear.example.com/.well-known/oauth-authorization-server": { + body: { + authorization_endpoint: "https://mcp.linear.example.com/authorize", + }, + }, + }); + + const resolved = await resolveMigrationOAuthAuthorizationUrls(migrationPlan([client]), { + fetch: fixture.fetch, + }); + + expect(fixture.seen).toEqual([ + "https://mcp.linear.example.com/.well-known/oauth-protected-resource/mcp", + "https://mcp.linear.example.com/.well-known/oauth-authorization-server", + ]); + expect(resolved.get(migrationOAuthClientPlanKey(client))).toBe( + "https://mcp.linear.example.com/authorize", + ); + }); + + it("discovers an authorization endpoint from an issuer-root authorization URL", async () => { + const client = oauthClient({ + slug: "spotify", + authorizationUrl: "https://accounts.example.com", + tokenUrl: "https://accounts.example.com/api/token", + }); + const fixture = fetchFixture({ + "https://accounts.example.com/.well-known/oauth-authorization-server": { + body: { + authorization_endpoint: "https://accounts.example.com/authorize", + }, + }, + }); + + const resolved = await resolveMigrationOAuthAuthorizationUrls(migrationPlan([client]), { + fetch: fixture.fetch, + }); + + expect(resolved.get(migrationOAuthClientPlanKey(client))).toBe( + "https://accounts.example.com/authorize", + ); + }); + + it("leaves an existing authorization endpoint unchanged when discovery cannot prove a replacement", async () => { + const client = oauthClient({ + slug: "apollo", + authorizationUrl: "https://mcp.apollo.example.com/mcp/oauth_metadata/redirect_to_authorize", + tokenUrl: "https://mcp.apollo.example.com/api/v1/oauth/token", + }); + const fixture = fetchFixture({}); + + const resolved = await resolveMigrationOAuthAuthorizationUrls(migrationPlan([client]), { + fetch: fixture.fetch, + }); + + expect(resolved.has(migrationOAuthClientPlanKey(client))).toBe(false); + expect(migrationOAuthAuthorizationUrlFor(client, resolved)).toBe( + "https://mcp.apollo.example.com/mcp/oauth_metadata/redirect_to_authorize", + ); + }); + + it("marks metadata, resource, and issuer-backed authorization-code clients as discoverable", () => { + expect( + migrationOAuthClientNeedsAuthorizationUrlResolution( + oauthClient({ authorizationServerMetadataUrl: "https://oauth.example.com/metadata" }), + ), + ).toBe(true); + expect( + migrationOAuthClientNeedsAuthorizationUrlResolution( + oauthClient({ resource: "https://mcp.example.com/mcp" }), + ), + ).toBe(true); + expect(migrationOAuthClientNeedsAuthorizationUrlResolution(oauthClient())).toBe(true); + expect( + migrationOAuthClientNeedsAuthorizationUrlResolution( + oauthClient({ grant: "client_credentials", authorizationUrl: "", resource: null }), + ), + ).toBe(false); + expect( + migrationOAuthClientAuthorizationUrlResolutionSource( + oauthClient({ + authorizationServerMetadataUrl: "", + resource: "https://mcp.example.com/mcp", + }), + ), + ).toBe("https://mcp.example.com/mcp"); + }); +}); diff --git a/packages/core/sdk/src/migration-oauth-metadata.ts b/packages/core/sdk/src/migration-oauth-metadata.ts new file mode 100644 index 000000000..4f91d84df --- /dev/null +++ b/packages/core/sdk/src/migration-oauth-metadata.ts @@ -0,0 +1,253 @@ +/* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: v1 migration resolves archived OAuth metadata before committing migrated rows */ + +import { Schema } from "effect"; + +import type { MigrationPlan } from "./migration-spec"; + +const OAuthAuthorizationServerMetadata = Schema.Struct({ + authorization_endpoint: Schema.String, +}); +const decodeOAuthAuthorizationServerMetadata = Schema.decodeUnknownSync( + OAuthAuthorizationServerMetadata, +); +const OAuthProtectedResourceMetadata = Schema.Struct({ + authorization_servers: Schema.optional(Schema.Array(Schema.String)), +}); +const decodeOAuthProtectedResourceMetadata = Schema.decodeUnknownSync( + OAuthProtectedResourceMetadata, +); +const DEFAULT_OAUTH_METADATA_TIMEOUT_MS = 20_000; + +export type MigrationOAuthMetadataFetch = ( + input: string, + init: { + readonly headers: Readonly>; + readonly signal: AbortSignal; + }, +) => Promise<{ + readonly ok: boolean; + readonly status: number; + readonly json: () => Promise; +}>; + +export interface ResolveMigrationOAuthAuthorizationUrlsOptions { + readonly fetch?: MigrationOAuthMetadataFetch; + readonly timeoutMs?: number; +} + +export const migrationOAuthClientPlanKey = ( + client: MigrationPlan["oauthClients"][number], +): string => + `${client.ownerKeys.tenant}\0${client.ownerKeys.owner}\0${client.ownerKeys.subject}\0${client.slug}`; + +const validateMigrationOAuthUrl = (value: string, label: string): string => { + const trimmed = value.trim(); + const url = new URL(trimmed); + const loopbackHttp = + url.protocol === "http:" && + (url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]"); + if (url.protocol !== "https:" && !loopbackHttp) { + throw new Error(`${label} must use https: or loopback http: ${trimmed}`); + } + return trimmed; +}; + +const fetchOAuthJson = async ( + url: string, + fetchImpl: MigrationOAuthMetadataFetch, + timeoutMs: number, +): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`OAuth metadata ${url} returned HTTP ${response.status}`); + } + return await response.json(); + } finally { + clearTimeout(timeout); + } +}; + +const fetchOAuthAuthorizationEndpoint = async ( + metadataUrl: string, + fetchImpl: MigrationOAuthMetadataFetch, + timeoutMs: number, +): Promise => { + const metadata = decodeOAuthAuthorizationServerMetadata( + await fetchOAuthJson(metadataUrl, fetchImpl, timeoutMs), + ); + return validateMigrationOAuthUrl(metadata.authorization_endpoint, "authorization_endpoint"); +}; + +const wellKnownAuthorizationServerUrlFor = ( + issuer: string, + algorithm: "oauth2" | "oidc", +): string => { + const url = new URL(validateMigrationOAuthUrl(issuer, "issuer")); + const origin = `${url.protocol}//${url.host}`; + const path = url.pathname.replace(/\/+$/, ""); + const suffix = algorithm === "oauth2" ? "oauth-authorization-server" : "openid-configuration"; + return path && path !== "/" + ? `${origin}/.well-known/${suffix}${path}` + : `${origin}/.well-known/${suffix}`; +}; + +const resolveAuthorizationEndpointFromIssuer = async ( + issuer: string, + fetchImpl: MigrationOAuthMetadataFetch, + timeoutMs: number, +): Promise => { + for (const algorithm of ["oauth2", "oidc"] as const) { + try { + return await fetchOAuthAuthorizationEndpoint( + wellKnownAuthorizationServerUrlFor(issuer, algorithm), + fetchImpl, + timeoutMs, + ); + } catch { + // Best-effort fallback discovery: try the next standards-defined location. + } + } + return null; +}; + +const protectedResourceMetadataUrlsFor = (resource: string): readonly string[] => { + const url = new URL(validateMigrationOAuthUrl(resource, "resource")); + const origin = `${url.protocol}//${url.host}`; + const path = url.pathname.replace(/\/+$/, ""); + const urls: string[] = []; + if (path && path !== "/") { + urls.push(`${origin}/.well-known/oauth-protected-resource${path}`); + } + urls.push(`${origin}/.well-known/oauth-protected-resource`); + return urls; +}; + +const discoverAuthorizationServersFromResource = async ( + resource: string, + fetchImpl: MigrationOAuthMetadataFetch, + timeoutMs: number, +): Promise => { + for (const metadataUrl of protectedResourceMetadataUrlsFor(resource)) { + try { + const metadata = decodeOAuthProtectedResourceMetadata( + await fetchOAuthJson(metadataUrl, fetchImpl, timeoutMs), + ); + return metadata.authorization_servers ?? []; + } catch { + // Best-effort fallback discovery: try the next protected-resource location. + } + } + return []; +}; + +const pushCandidate = (candidates: string[], value: string | null | undefined): void => { + const trimmed = value?.trim(); + if (!trimmed || candidates.includes(trimmed)) return; + candidates.push(trimmed); +}; + +const resolveMigrationOAuthAuthorizationUrl = async ( + client: MigrationPlan["oauthClients"][number], + fetchImpl: MigrationOAuthMetadataFetch, + timeoutMs: number, +): Promise => { + const metadataUrl = client.authorizationServerMetadataUrl?.trim(); + if (metadataUrl) { + // Best-effort like the resource/issuer discovery below: a dead, invalid, + // or unreachable metadata endpoint (offline machine, archived server) + // must not abort the whole migration — fall through to discovery. + try { + const endpoint = await fetchOAuthAuthorizationEndpoint( + validateMigrationOAuthUrl(metadataUrl, "authorizationServerMetadataUrl"), + fetchImpl, + timeoutMs, + ); + if (endpoint) return endpoint; + } catch { + // fall through to issuer/resource discovery + } + } + + if (client.grant !== "authorization_code") return null; + + const candidates: string[] = []; + if (client.resource?.trim()) { + try { + for (const issuer of await discoverAuthorizationServersFromResource( + client.resource, + fetchImpl, + timeoutMs, + )) { + pushCandidate(candidates, issuer); + } + } catch { + // Best-effort: fall through to the stored authorization URL candidate. + } + } + pushCandidate(candidates, client.authorizationUrl); + + for (const candidate of candidates) { + const endpoint = await resolveAuthorizationEndpointFromIssuer(candidate, fetchImpl, timeoutMs); + if (endpoint) return endpoint; + } + return null; +}; + +export const migrationOAuthClientNeedsAuthorizationUrlResolution = ( + client: MigrationPlan["oauthClients"][number], +): boolean => + !!client.authorizationServerMetadataUrl?.trim() || + (client.grant === "authorization_code" && + (!!client.resource?.trim() || !!client.authorizationUrl.trim())); + +export const migrationOAuthClientAuthorizationUrlResolutionSource = ( + client: MigrationPlan["oauthClients"][number], +): string => + client.authorizationServerMetadataUrl?.trim() || + client.resource?.trim() || + client.authorizationUrl.trim(); + +export const resolveMigrationOAuthAuthorizationUrls = async ( + plan: MigrationPlan, + options: ResolveMigrationOAuthAuthorizationUrlsOptions = {}, +): Promise> => { + const clientsToResolve = plan.oauthClients.filter( + migrationOAuthClientNeedsAuthorizationUrlResolution, + ); + if (clientsToResolve.length === 0) return new Map(); + + const fetchImpl = options.fetch; + if (!fetchImpl) { + throw new Error("OAuth metadata resolution requires an injected fetch implementation."); + } + const timeoutMs = options.timeoutMs ?? DEFAULT_OAUTH_METADATA_TIMEOUT_MS; + const endpointByPlanKey = new Map>(); + const resolved = new Map(); + + for (const client of clientsToResolve) { + const key = migrationOAuthClientPlanKey(client); + let endpoint = endpointByPlanKey.get(key); + if (!endpoint) { + endpoint = resolveMigrationOAuthAuthorizationUrl(client, fetchImpl, timeoutMs); + endpointByPlanKey.set(key, endpoint); + } + const authorizationUrl = await endpoint; + if (authorizationUrl) resolved.set(key, authorizationUrl); + } + + return resolved; +}; + +export const migrationOAuthAuthorizationUrlFor = ( + client: MigrationPlan["oauthClients"][number], + resolvedUrls: ReadonlyMap, +): string => resolvedUrls.get(migrationOAuthClientPlanKey(client)) ?? client.authorizationUrl; diff --git a/packages/core/sdk/src/migration-spec.test.ts b/packages/core/sdk/src/migration-spec.test.ts new file mode 100644 index 000000000..abc0472b4 --- /dev/null +++ b/packages/core/sdk/src/migration-spec.test.ts @@ -0,0 +1,1853 @@ +import { describe, it, expect } from "@effect/vitest"; + +import { + parseScope, + ownerPartitionKey, + vaultV1ObjectName, + vaultV1LegacyObjectName, + vaultV2ObjectName, + oauthClientDedupKey, + serializeOAuthScopes, + migratePolicyPattern, + migrateOpenApiAuthTemplate, + API_KEY_TEMPLATE_SLUG, + migrateGrant, + migrateExpiresAt, + SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS, + migrateOpenApiSourceConfig, + migrateSourceAuth, + classifyBindingSlot, + dedupeOAuthClients, + oauthClientSlugKey, + planIntegrationRow, + planConnectionRow, + migratedItemId, + migrateMcpSourceConfig, + migrateGraphqlSourceConfig, + OAUTH_TEMPLATE_SLUG, + planMigration, + buildV1RuntimeMetadataIndex, + migrateV1PluginStorageRuntimeRow, + migrateV1ToolAnnotations, +} from "./migration-spec"; +import type { MigrationInput, MigratedSourceConfig } from "./migration-spec"; + +describe("parseScope", () => { + it("maps a bare org scope to owner=org, empty subject", () => { + expect(parseScope("org_01KRFBFKMP")).toEqual({ + owner: "org", + subject: "", + tenant: "org_01KRFBFKMP", + }); + }); + + it("maps a user-org scope to owner=user with subject + tenant", () => { + expect(parseScope("user-org:user_01ABC:org_01XYZ")).toEqual({ + owner: "user", + subject: "user_01ABC", + tenant: "org_01XYZ", + }); + }); + + it("fails loud (null) on unknown shapes rather than mis-owning", () => { + expect(parseScope("user_01ABC")).toBeNull(); // bare user, no org — not a v2 shape + expect(parseScope("local-folder-hash")).toBeNull(); + expect(parseScope("user-org:org_01XYZ")).toBeNull(); // missing user segment + expect(parseScope("user-org:user_01:org_02:extra")).toBeNull(); // too many segments + expect(parseScope("org_01:trailing")).toBeNull(); // org with a colon + expect(parseScope("")).toBeNull(); + }); +}); + +describe("ownerPartitionKey", () => { + it("collapses org apps across the org, keeps user apps per-user", () => { + expect(ownerPartitionKey({ owner: "org", subject: "", tenant: "org_X" })).toBe("org:org_X"); + expect(ownerPartitionKey({ owner: "user", subject: "user_U", tenant: "org_X" })).toBe( + "user:user_U:org_X", + ); + }); +}); + +describe("vault object naming", () => { + it("v1 name carries the scope segment + url-encodes both parts", () => { + expect(vaultV1ObjectName("executor", "user-org:user_U:org_O", "sec_a/b")).toBe( + "executor/user-org%3Auser_U%3Aorg_O/secrets/sec_a%2Fb", + ); + }); + + it("v1 legacy name leaves the segments un-encoded (the 404 fallback)", () => { + expect(vaultV1LegacyObjectName("executor", "org_X", "sec_a")).toBe( + "executor/org_X/secrets/sec_a", + ); + }); + + it("v2 name drops the scope segment (flat namespace)", () => { + expect(vaultV2ObjectName("executor", "item_a/b")).toBe("executor/secrets/item_a%2Fb"); + }); + + it("v1 and v2 names differ → id-reuse is impossible (the whole reason to re-key)", () => { + const scope = "org_X"; + const id = "sec_1"; + expect(vaultV1ObjectName("executor", scope, id)).not.toBe(vaultV2ObjectName("executor", id)); + }); +}); + +describe("oauthClientDedupKey", () => { + it("merges identical (partition, clientId, tokenEndpoint), distinguishes any difference", () => { + const a = oauthClientDedupKey("org:org_X", "cid", "https://t/token"); + const b = oauthClientDedupKey("org:org_X", "cid", "https://t/token"); + const c = oauthClientDedupKey("user:u:org_X", "cid", "https://t/token"); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); +}); + +describe("serializeOAuthScopes", () => { + it("space-joins, de-dupes order-preserving, drops empties", () => { + expect(serializeOAuthScopes(["data", "api", "data", ""])).toBe("data api"); + expect(serializeOAuthScopes(["vanta-api.all:read", "vanta-api.vendors:read"])).toBe( + "vanta-api.all:read vanta-api.vendors:read", + ); + expect(serializeOAuthScopes([])).toBe(""); + }); +}); + +describe("migratePolicyPattern", () => { + const M = new Map([ + ["github_v3_rest_api", "github"], // a rename + ["microsoft_graph", "microsoft_graph"], // no-op slug + ["dealcloud_api", "dealcloud_api"], + ]); + + it("universal pattern passes through", () => { + expect(migratePolicyPattern("*", M)).toEqual({ kind: "ok", pattern: "*" }); + }); + + it("static namespaces pass through verbatim", () => { + expect(migratePolicyPattern("executor.openapi.addSource", M)).toEqual({ + kind: "static", + pattern: "executor.openapi.addSource", + }); + expect(migratePolicyPattern("openapi.addSource", M)).toEqual({ + kind: "static", + pattern: "openapi.addSource", + }); + }); + + it("whole-integration (`slug.*`) only remaps the slug — trailing * already a subtree", () => { + expect(migratePolicyPattern("dealcloud_api.*", M)).toEqual({ + kind: "ok", + pattern: "dealcloud_api.*", + }); + expect(migratePolicyPattern("github_v3_rest_api.*", M)).toEqual({ + kind: "ok", + pattern: "github.*", + }); + }); + + it("exact / subtree patterns insert the owner+connection wildcards", () => { + expect(migratePolicyPattern("microsoft_graph.meEvent.meEventsEventCancel", M)).toEqual({ + kind: "ok", + pattern: "microsoft_graph.*.*.meEvent.meEventsEventCancel", + }); + expect(migratePolicyPattern("github_v3_rest_api.repos.deleteAccessRestrictions", M)).toEqual({ + kind: "ok", + pattern: "github.*.*.repos.deleteAccessRestrictions", + }); + expect(migratePolicyPattern("microsoft_graph.meCalendar.*", M)).toEqual({ + kind: "ok", + pattern: "microsoft_graph.*.*.meCalendar.*", + }); + }); + + it("an unknown first segment is flagged DEAD (source removed) — never silently dropped", () => { + expect(migratePolicyPattern("api_githubcopilot_com.delete_file", M)).toEqual({ + kind: "dead", + slug: "api_githubcopilot_com", + }); + }); +}); + +describe("plugin runtime metadata migration", () => { + it("stamps MCP tool annotations from the legacy binding without changing the Executor slug", () => { + const index = buildV1RuntimeMetadataIndex([ + { + scopeId: "org_X", + pluginId: "mcp", + collection: "binding", + key: "axiom_mcp.querydataset", + data: { + namespace: "axiom_mcp", + toolId: "axiom_mcp.querydataset", + binding: { + toolId: "querydataset", + toolName: "queryDataset", + annotations: { title: "Query dataset", readOnlyHint: true }, + }, + }, + }, + ]); + + const annotations = migrateV1ToolAnnotations( + { + scopeId: "org_X", + sourceId: "axiom_mcp", + pluginId: "mcp", + name: "querydataset", + annotations: null, + }, + index, + ); + + expect(annotations).toEqual({ + requiresApproval: false, + mcp: { + toolName: "queryDataset", + upstream: { title: "Query dataset", readOnlyHint: true }, + }, + }); + }); + + it("keeps already-stamped MCP annotations unchanged for idempotent re-runs", () => { + const annotations = { + requiresApproval: true, + mcp: { toolName: "deleteDataset", upstream: { destructiveHint: true } }, + }; + + expect( + migrateV1ToolAnnotations( + { + scopeId: "org_X", + sourceId: "axiom_mcp", + pluginId: "mcp", + name: "deletedataset", + annotations, + }, + buildV1RuntimeMetadataIndex([]), + ), + ).toBe(annotations); + }); + + it("rewrites v1 OpenAPI operation storage to the v2 catalog-owned shape", () => { + expect( + migrateV1PluginStorageRuntimeRow({ + scopeId: "user-org:user_U:org_X", + pluginId: "openapi", + collection: "operation", + key: "vercel_api.dns.getRecords", + data: { + toolId: "vercel_api.dns.getRecords", + sourceId: "vercel_api", + binding: { method: "get", pathTemplate: "/v4/domains/{domain}/records" }, + }, + }), + ).toEqual({ + pluginId: "openapi", + collection: "operation", + key: "vercel_api.dns.getRecords", + data: { + integration: "vercel_api", + toolName: "dns.getRecords", + binding: { method: "get", pathTemplate: "/v4/domains/{domain}/records" }, + }, + owner: "catalog", + }); + }); + + it("rewrites v1 GraphQL operation storage and normalizes graphql-greenfield ids", () => { + expect( + migrateV1PluginStorageRuntimeRow({ + scopeId: "org_X", + pluginId: "graphql-greenfield", + collection: "operation", + key: "graphql_api.query.hello", + data: { + toolId: "graphql_api.query.hello", + sourceId: "graphql_api", + binding: { kind: "query", fieldName: "hello", operationString: "query { hello }" }, + }, + }), + ).toEqual({ + pluginId: "graphql", + collection: "operation", + key: "graphql_api.query.hello", + data: { + integration: "graphql_api", + toolName: "query.hello", + binding: { kind: "query", fieldName: "hello", operationString: "query { hello }" }, + }, + owner: "catalog", + }); + }); + + it("keeps source-owned plugin storage rows source-owned", () => { + expect( + migrateV1PluginStorageRuntimeRow({ + scopeId: "org_X", + pluginId: "onepassword", + collection: "settings", + key: "config", + data: { vaultId: "vault_123" }, + }), + ).toEqual({ + pluginId: "onepassword", + collection: "settings", + key: "config", + data: { vaultId: "vault_123" }, + owner: "source", + }); + }); +}); + +describe("migrateOpenApiAuthTemplate", () => { + it("maps a single Bearer apiKey header to one apiKey method (prefix preserved)", () => { + const r = migrateOpenApiAuthTemplate({ + headers: { + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + }, + }); + expect(r.authenticationTemplate).toEqual([ + { + slug: API_KEY_TEMPLATE_SLUG, + type: "apiKey", + headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ]); + expect(r.slotToTemplateSlug).toEqual({ "header:authorization": API_KEY_TEMPLATE_SLUG }); + expect(r.slotToVariable).toEqual({ "header:authorization": "token" }); + expect(r.staticHeaders).toEqual({}); + }); + + it("a prefix-less apiKey header renders a bare [token]", () => { + const r = migrateOpenApiAuthTemplate({ + headers: { "X-Api-Key": { kind: "binding", slot: "header:x-api-key" } }, + }); + expect(r.authenticationTemplate[0]).toEqual({ + slug: API_KEY_TEMPLATE_SLUG, + type: "apiKey", + headers: { "X-Api-Key": [{ type: "variable", name: "token" }] }, + }); + }); + + it("a query-param api key lands in queryParams, not headers", () => { + const r = migrateOpenApiAuthTemplate({ + queryParams: { key: { kind: "binding", slot: "query_param:key" } }, + }); + expect(r.authenticationTemplate[0]).toEqual({ + slug: API_KEY_TEMPLATE_SLUG, + type: "apiKey", + queryParams: { key: [{ type: "variable", name: "token" }] }, + }); + expect(r.slotToTemplateSlug).toEqual({ "query_param:key": API_KEY_TEMPLATE_SLUG }); + }); + + it("literal-string headers pass through as static, never as credentials", () => { + const r = migrateOpenApiAuthTemplate({ + headers: { + "User-Agent": "executor/1.0", + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + }, + }); + expect(r.staticHeaders).toEqual({ "User-Agent": "executor/1.0" }); + expect(r.authenticationTemplate).toHaveLength(1); + // The lone credential placement stays the canonical `token`. + expect(r.slotToVariable).toEqual({ "header:authorization": "token" }); + }); + + it("converts oauth2 to an oauth method keyed on its security-scheme slug", () => { + const r = migrateOpenApiAuthTemplate({ + oauth2: { + securitySchemeName: "googleOAuth2", + flow: "authorizationCode", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + scopes: ["https://www.googleapis.com/auth/calendar"], + }, + }); + expect(r.authenticationTemplate).toEqual([ + { + slug: "googleOAuth2", + type: "oauth", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + scopes: ["https://www.googleapis.com/auth/calendar"], + }, + ]); + expect(r.slotToTemplateSlug).toEqual({ "oauth2:googleoauth2:connection": "googleOAuth2" }); + }); + + it("maps hyphenated legacy oauth slot names to underscored oauth slugs", () => { + const r = migrateOpenApiAuthTemplate({ + oauth2: { + securitySchemeName: "oauth_2_0", + flow: "authorizationCode", + authorizationUrl: "https://accounts.spotify.com/authorize", + tokenUrl: "https://accounts.spotify.com/api/token", + scopes: ["user-read-email"], + }, + }); + + expect(r.slotToTemplateSlug["oauth2:oauth-2-0:connection"]).toBe("oauth_2_0"); + expect(r.slotToVariable["oauth2:oauth-2-0:connection"]).toBe("token"); + }); + + it("a source offering both apiKey and oauth declares both methods", () => { + const r = migrateOpenApiAuthTemplate({ + headers: { + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + }, + oauth2: { + securitySchemeName: "oauth2", + tokenUrl: "https://example.com/token", + scopes: [], + }, + }); + expect(r.authenticationTemplate.map((m) => m.type)).toEqual(["apiKey", "oauth"]); + }); + + it("gives two distinct credential placements (Datadog) their own variables", () => { + const r = migrateOpenApiAuthTemplate({ + headers: { + "DD-API-KEY": { kind: "binding", slot: "header:dd-api-key" }, + "DD-APPLICATION-KEY": { kind: "binding", slot: "header:dd-application-key" }, + }, + }); + expect(r.authenticationTemplate).toEqual([ + { + slug: API_KEY_TEMPLATE_SLUG, + type: "apiKey", + headers: { + "DD-API-KEY": [{ type: "variable", name: "dd_api_key" }], + "DD-APPLICATION-KEY": [{ type: "variable", name: "dd_application_key" }], + }, + }, + ]); + expect(r.slotToVariable).toEqual({ + "header:dd-api-key": "dd_api_key", + "header:dd-application-key": "dd_application_key", + }); + expect(r.warnings).toEqual([]); + }); + + it("an auth-less source yields an empty template", () => { + const r = migrateOpenApiAuthTemplate({}); + expect(r.authenticationTemplate).toEqual([]); + expect(r.slotToTemplateSlug).toEqual({}); + expect(r.slotToVariable).toEqual({}); + }); +}); + +describe("migrateGrant", () => { + it("maps client-credentials to client_credentials, everything else to authorization_code", () => { + expect(migrateGrant("client-credentials")).toBe("client_credentials"); + expect(migrateGrant("authorization-code")).toBe("authorization_code"); + expect(migrateGrant("dynamic-dcr")).toBe("authorization_code"); + }); +}); + +describe("migrateExpiresAt (C1a)", () => { + const now = 1_700_000_000_000; + + it("synthesizes a 1h expiry for a client_credentials connection with null v1 expiry", () => { + expect(migrateExpiresAt({ grant: "client_credentials", v1ExpiresAt: null, nowMs: now })).toBe( + now + SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS, + ); + expect(SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS).toBe(60 * 60 * 1000); + }); + + it("keeps a real v1 expiry for client_credentials (only backfills the null case)", () => { + expect(migrateExpiresAt({ grant: "client_credentials", v1ExpiresAt: 123, nowMs: now })).toBe( + 123, + ); + }); + + it("never synthesizes for authorization_code (null stays null — re-auth on use)", () => { + expect( + migrateExpiresAt({ grant: "authorization_code", v1ExpiresAt: null, nowMs: now }), + ).toBeNull(); + expect(migrateExpiresAt({ grant: "authorization_code", v1ExpiresAt: 456, nowMs: now })).toBe( + 456, + ); + }); +}); + +describe("migrateOpenApiSourceConfig", () => { + it("copies structural fields, drops namespace, and converts auth to a template", () => { + const r = migrateOpenApiSourceConfig({ + spec: "{openapi}", + sourceUrl: "https://api.example.com/openapi.json", + baseUrl: "https://api.example.com", + headers: { + "User-Agent": "executor/1.0", + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + }, + }); + const config = r.config as { + readonly spec?: string; + readonly sourceUrl?: string; + readonly baseUrl?: string; + readonly headers?: Record; + readonly authenticationTemplate?: unknown; + }; + expect(config.spec).toBe("{openapi}"); + expect(config.sourceUrl).toBe("https://api.example.com/openapi.json"); + expect(config.baseUrl).toBe("https://api.example.com"); + // The literal header is static config; the credential header became a template. + expect(config.headers).toEqual({ "User-Agent": "executor/1.0" }); + expect(config.authenticationTemplate).toEqual([ + { + slug: API_KEY_TEMPLATE_SLUG, + type: "apiKey", + headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ]); + expect(r.slotToVariable).toEqual({ "header:authorization": "token" }); + // namespace is never carried into v2 config. + expect("namespace" in (r.config as object)).toBe(false); + }); + + it("omits absent fields and emits no empty template for an auth-less source", () => { + const r = migrateOpenApiSourceConfig({ spec: "{}" }); + expect(r.config).toEqual({ spec: "{}" }); + }); +}); + +describe("migrateSourceAuth (mcp/graphql)", () => { + it("strips the connection slot from an oauth2 auth block", () => { + expect(migrateSourceAuth({ kind: "oauth2", connectionSlot: "auth:oauth2:connection" })).toEqual( + { + kind: "oauth2", + }, + ); + }); + + it("passes through none, and treats absent auth as none", () => { + expect(migrateSourceAuth({ kind: "none" })).toEqual({ kind: "none" }); + expect(migrateSourceAuth(undefined)).toEqual({ kind: "none" }); + }); +}); + +describe("classifyBindingSlot", () => { + it("classifies api-key carriers (header / query / spec-fetch)", () => { + expect(classifyBindingSlot("header:authorization")).toBe("apikey"); + expect(classifyBindingSlot("query_param:key")).toBe("apikey"); + expect(classifyBindingSlot("spec_fetch_header:authorization")).toBe("apikey"); + }); + + it("classifies BYO client credential slots", () => { + expect(classifyBindingSlot("oauth2:azureaddelegated:client-secret")).toBe("client-secret"); + expect(classifyBindingSlot("oauth2:googleoauth2:client-id")).toBe("client-id"); + }); + + it("classifies an oauth connection slot as the access token", () => { + expect(classifyBindingSlot("oauth2:oauth2:connection")).toBe("oauth-access"); + expect(classifyBindingSlot("auth:oauth2:connection")).toBe("oauth-access"); + }); +}); + +describe("dedupeOAuthClients", () => { + const orgKeys = { owner: "org" as const, subject: "", tenant: "org_X" }; + const userKeys = { owner: "user" as const, subject: "user_U", tenant: "org_X" }; + + it("folds identical apps within a partition, keeps distinct ones apart", () => { + const r = dedupeOAuthClients([ + { + ownerKeys: orgKeys, + clientId: "cid", + tokenUrl: "https://tenant.dealcloud.example/oauth/token", + authorizationUrl: "", + grant: "client_credentials", + resource: null, + clientSecretRef: "sec_a", + }, + // identical (same partition + clientId + tokenUrl) → folds away + { + ownerKeys: orgKeys, + clientId: "cid", + tokenUrl: "https://tenant.dealcloud.example/oauth/token", + authorizationUrl: "", + grant: "client_credentials", + resource: null, + clientSecretRef: "sec_a", + }, + // same clientId but a different USER partition → stays separate + { + ownerKeys: userKeys, + clientId: "cid", + tokenUrl: "https://tenant.dealcloud.example/oauth/token", + authorizationUrl: "", + grant: "client_credentials", + resource: null, + clientSecretRef: "sec_b", + }, + ]); + expect(r.clients).toHaveLength(2); + expect(r.clients[0]?.slug).toBe("dealcloud"); + // distinct partition reuses the same host-derived slug (slugs are unique + // only WITHIN a partition). + expect(r.clients[1]?.slug).toBe("dealcloud"); + }); + + it("keeps secret-backed client ids distinct until runners resolve their values", () => { + const r = dedupeOAuthClients([ + { + ownerKeys: orgKeys, + clientId: "", + clientIdSecretRef: { scopeId: "org_X", secretId: "client-id-a", provider: "workos" }, + tokenUrl: "https://tenant.dealcloud.example/oauth/token", + authorizationUrl: "", + grant: "client_credentials", + resource: null, + clientSecretRef: "sec_a", + }, + { + ownerKeys: orgKeys, + clientId: "", + clientIdSecretRef: { scopeId: "org_X", secretId: "client-id-b", provider: "workos" }, + tokenUrl: "https://tenant.dealcloud.example/oauth/token", + authorizationUrl: "", + grant: "client_credentials", + resource: null, + clientSecretRef: "sec_b", + }, + ]); + expect(r.clients.map((c) => c.slug)).toEqual(["dealcloud", "dealcloud_2"]); + }); + + it("disambiguates two distinct apps in the same partition by suffix", () => { + const r = dedupeOAuthClients([ + { + ownerKeys: orgKeys, + clientId: "a", + tokenUrl: "https://api.acme.com/token", + authorizationUrl: "", + grant: "authorization_code", + resource: null, + clientSecretRef: null, + }, + { + ownerKeys: orgKeys, + clientId: "b", + tokenUrl: "https://api.acme.com/token", + authorizationUrl: "", + grant: "authorization_code", + resource: null, + clientSecretRef: null, + }, + ]); + expect(r.clients.map((c) => c.slug)).toEqual(["acme", "acme_2"]); + }); + + it("the slug lookup key round-trips an app to its assigned slug", () => { + const r = dedupeOAuthClients([ + { + ownerKeys: orgKeys, + clientId: "cid", + tokenUrl: "https://api.vanta.com/oauth/token", + authorizationUrl: "", + grant: "client_credentials", + resource: null, + clientSecretRef: null, + }, + ]); + const key = oauthClientSlugKey({ + ownerKeys: orgKeys, + clientId: "cid", + tokenUrl: "https://api.vanta.com/oauth/token", + }); + expect(r.slugByDedupKey[key]).toBe("vanta"); + }); +}); + +describe("planIntegrationRow", () => { + it("derives owner/subject/tenant from the source scope, slug = source id", () => { + expect( + planIntegrationRow({ + scopeId: "org_X", + sourceId: "dealcloud_api", + pluginId: "openapi", + description: "DealCloud", + config: { spec: "{}" }, + }), + ).toEqual({ + tenant: "org_X", + owner: "org", + subject: "", + slug: "dealcloud_api", + plugin_id: "openapi", + description: "DealCloud", + config: { spec: "{}" }, + }); + }); + + it("fails loud (null) on an unparseable scope rather than mis-owning", () => { + expect( + planIntegrationRow({ + scopeId: "weird-scope", + sourceId: "x", + pluginId: "openapi", + description: "", + config: {}, + }), + ).toBeNull(); + }); +}); + +describe("planConnectionRow", () => { + const now = 1_700_000_000_000; + + it("splits a user-org scope, joins scopes, and carries the oauth client owner", () => { + const row = planConnectionRow({ + scopeId: "user-org:user_U:org_O", + integration: "github", + name: "personal", + template: "googleOAuth2", + provider: "workos-vault", + identityLabel: "me@example.com", + grant: "authorization_code", + v1ExpiresAt: 999, + oauthScopes: ["read", "write", "read"], + oauthClientSlug: "github", + oauthClientOwner: { owner: "org", subject: "", tenant: "org_O" }, + nowMs: now, + }); + expect(row).toEqual({ + tenant: "org_O", + owner: "user", + subject: "user_U", + integration: "github", + name: "personal", + template: "googleOAuth2", + provider: "workos-vault", + identityLabel: "me@example.com", + oauthClientSlug: "github", + oauthClientOwner: "org", + oauthScope: "read write", + expiresAt: 999, + }); + }); + + it("applies the C1a synthetic expiry for a null-expiry client_credentials connection", () => { + const row = planConnectionRow({ + scopeId: "org_X", + integration: "dealcloud_api", + name: "service", + template: "oauth2", + provider: "workos-vault", + identityLabel: null, + grant: "client_credentials", + v1ExpiresAt: null, + oauthScopes: [], + oauthClientSlug: "dealcloud", + oauthClientOwner: { owner: "org", subject: "", tenant: "org_X" }, + nowMs: now, + }); + expect(row?.expiresAt).toBe(now + SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS); + expect(row?.oauthScope).toBeNull(); + }); +}); + +describe("migratedItemId", () => { + it("is deterministic, opaque, and differs from any v1 name", () => { + expect(migratedItemId("user-org:user_U:org_O", "sec_a/b")).toMatch( + /^secret_[A-Za-z0-9_-]{43}$/, + ); + expect(migratedItemId("user-org:user_U:org_O", "sec_a/b")).not.toContain("user-org"); + expect(migratedItemId("user-org:user_U:org_O", "sec_a/b")).not.toContain("sec_a"); + // same inputs → same id (a re-run reuses the vault item, no duplicate write). + expect(migratedItemId("org_X", "sec_1")).toBe(migratedItemId("org_X", "sec_1")); + expect(migratedItemId("org_X", "sec_1")).not.toBe(migratedItemId("org_Y", "sec_1")); + }); +}); + +describe("migrateMcpSourceConfig / migrateGraphqlSourceConfig", () => { + it("mcp: copies endpoint/transport, an apikey header → template, oauth2 auth → slot map", () => { + const r = migrateMcpSourceConfig({ + endpoint: "https://api.example.com/mcp", + transport: "remote", + remoteTransport: "auto", + headers: { + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + }, + auth: { kind: "none" }, + }); + const config = r.config as { + readonly endpoint?: string; + readonly transport?: string; + readonly auth?: { readonly kind: string }; + readonly authenticationTemplate?: unknown; + }; + expect(config.endpoint).toBe("https://api.example.com/mcp"); + expect(config.transport).toBe("remote"); + expect(config.auth).toEqual({ kind: "none" }); + expect(config.authenticationTemplate).toEqual([ + { + slug: API_KEY_TEMPLATE_SLUG, + type: "apiKey", + headers: { Authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ]); + expect(r.slotToVariable).toEqual({ "header:authorization": "token" }); + }); + + it("mcp: an oauth2 auth block maps its connection slot to the conventional oauth template", () => { + const r = migrateMcpSourceConfig({ + endpoint: "https://mcp.example.com", + auth: { kind: "oauth2", connectionSlot: "auth:oauth2:connection" }, + }); + expect((r.config as { auth: unknown }).auth).toEqual({ kind: "oauth2" }); + expect(r.slotToTemplateSlug).toEqual({ "auth:oauth2:connection": OAUTH_TEMPLATE_SLUG }); + expect(r.slotToVariable).toEqual({ "auth:oauth2:connection": "token" }); + }); + + it("graphql: copies endpoint + converts a bearer header", () => { + const r = migrateGraphqlSourceConfig({ + endpoint: "https://api.github.com/graphql", + name: "Github GraphQL", + headers: { + Authorization: { kind: "binding", slot: "header:authorization", prefix: "Bearer " }, + }, + auth: { kind: "none" }, + }); + const config = r.config as { + readonly endpoint?: string; + readonly authenticationTemplate?: unknown; + }; + expect(config.endpoint).toBe("https://api.github.com/graphql"); + expect(config.authenticationTemplate).toBeDefined(); + }); +}); + +describe("planMigration (the weave)", () => { + const cfg = (over: Partial = {}): MigratedSourceConfig => ({ + config: {}, + slotToTemplateSlug: {}, + slotToVariable: {}, + warnings: [], + ...over, + }); + const now = 1_700_000_000_000; + + it("weaves a full snapshot into integrations, connections, oauth clients, secret ops, policies", () => { + const input: MigrationInput = { + nowMs: now, + sources: [ + { scopeId: "org_X", id: "stripe_api", pluginId: "openapi", name: "Stripe" }, + { scopeId: "user-org:user_U:org_X", id: "linear_mcp", pluginId: "mcp", name: "Linear MCP" }, + ], + migratedConfigs: new Map([ + [ + "org_X stripe_api", + cfg({ + slotToTemplateSlug: { "header:authorization": "apiKey" }, + slotToVariable: { "header:authorization": "token" }, + }), + ], + [ + "user-org:user_U:org_X linear_mcp", + cfg({ + slotToTemplateSlug: { "auth:oauth2:connection": "oauth2" }, + slotToVariable: { "auth:oauth2:connection": "token" }, + }), + ], + ]), + connections: [ + { + id: "mcp-oauth2-linear_mcp", + scopeId: "user-org:user_U:org_X", + provider: "workos-vault", + identityLabel: "Linear MCP OAuth", + accessTokenSecretId: "linear-access", + refreshTokenSecretId: "linear-refresh", + expiresAt: 555, + providerState: { + kind: "dynamic-dcr", + clientId: "cid-linear", + clientSecretSecretId: "linear-client-secret", + tokenEndpoint: "https://mcp.linear.app/token", + authorizationServerUrl: "https://mcp.linear.app/authorize", + authorizationServerMetadataUrl: + "https://mcp.linear.app/.well-known/oauth-authorization-server", + resource: "https://mcp.linear.app", + scopes: ["read", "write"], + }, + }, + ], + bindings: [ + { + scopeId: "org_X", + sourceId: "stripe_api", + slotKey: "header:authorization", + kind: "secret", + secretId: "stripe-key", + connectionId: null, + textValue: null, + }, + { + scopeId: "user-org:user_U:org_X", + sourceId: "linear_mcp", + slotKey: "auth:oauth2:connection", + kind: "connection", + secretId: null, + connectionId: "mcp-oauth2-linear_mcp", + textValue: null, + }, + ], + secrets: [ + { + id: "stripe-key", + scopeId: "org_X", + name: "Stripe key", + provider: "workos-vault", + ownedByConnectionId: null, + }, + { + id: "linear-access", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "mcp-oauth2-linear_mcp", + }, + { + id: "linear-refresh", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "mcp-oauth2-linear_mcp", + }, + { + id: "linear-client-secret", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + { + id: "loose-pat", + scopeId: "org_X", + name: "an orphan", + provider: "workos-vault", + ownedByConnectionId: null, + }, + ], + policies: [ + { scopeId: "org_X", pattern: "stripe_api.charges.create", action: "approve" }, + { scopeId: "org_X", pattern: "deadsource.delete", action: "block" }, + ], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + // Integrations: one per source, owner derived from scope. + expect(plan.integrations.map((i) => [i.slug, i.owner, i.subject])).toEqual([ + ["stripe_api", "org", ""], + ["linear_mcp", "user", "user_U"], + ]); + + // Connections: an apiKey (stripe) + an oauth (linear). + const stripe = plan.connections.find((c) => c.row.integration === "stripe_api"); + const linear = plan.connections.find((c) => c.row.integration === "linear_mcp"); + expect(stripe?.row.name).toBe("stripeKey"); + expect(stripe?.row.template).toBe("apiKey"); + expect(stripe?.itemIds.token).toBe(migratedItemId("org_X", "stripe-key")); + expect(stripe?.row.oauthClientSlug).toBeNull(); + + expect(linear?.row.name).toBe("linearMcpOauth"); + expect(linear?.row.template).toBe("oauth2"); + expect(linear?.row.owner).toBe("user"); + expect(linear?.itemIds.token).toBe(migratedItemId("user-org:user_U:org_X", "linear-access")); + expect(linear?.refreshItemId).toBe(migratedItemId("user-org:user_U:org_X", "linear-refresh")); + expect(linear?.row.oauthScope).toBe("read write"); + expect(linear?.row.expiresAt).toBe(555); + // Wired to its deduped client. + expect(linear?.row.oauthClientSlug).toBe("linear"); + + // OAuth client: one, with its secret re-keyed. + expect(plan.oauthClients).toHaveLength(1); + expect(plan.oauthClients[0]?.clientId).toBe("cid-linear"); + expect(plan.oauthClients[0]?.authorizationUrl).toBe("https://mcp.linear.app/authorize"); + expect(plan.oauthClients[0]?.authorizationServerMetadataUrl).toBe( + "https://mcp.linear.app/.well-known/oauth-authorization-server", + ); + expect(plan.oauthClients[0]?.resource).toBe("https://mcp.linear.app"); + expect(plan.oauthClients[0]?.clientSecretItemId).toBe( + migratedItemId("user-org:user_U:org_X", "linear-client-secret"), + ); + + // Secret ops: access, refresh, client-secret, apikey, + the orphan. + const roles = plan.secretOps.map((o) => o.role).sort(); + expect(roles).toEqual(["apikey", "client-secret", "oauth-access", "oauth-refresh", "orphan"]); + expect(plan.secretOps.find((o) => o.role === "orphan")?.itemId).toBe( + migratedItemId("org_X", "loose-pat"), + ); + + // Policies: live one transforms; dead one kept inert. + const live = plan.policies.find((p) => p.action === "approve"); + const dead = plan.policies.find((p) => p.action === "block"); + expect(live?.pattern).toBe("stripe_api.*.*.charges.create"); + expect(live?.status).toBe("ok"); + expect(dead?.status).toBe("dead-inert"); + expect(dead?.pattern).toBe("deadsource.delete"); // unchanged → matches no v2 address + + expect(plan.report.connections).toBe(2); + expect(plan.report.oauthClients).toBe(1); + expect(plan.report.policies).toEqual({ ok: 1, static: 0, deadInert: 1 }); + }); + + it("uses discovered MCP OAuth resource overrides instead of stale provider state", () => { + const input: MigrationInput = { + nowMs: now, + sources: [ + { scopeId: "user-org:user_U:org_X", id: "linear_mcp", pluginId: "mcp", name: "Linear MCP" }, + ], + migratedConfigs: new Map([ + [ + "user-org:user_U:org_X linear_mcp", + cfg({ + slotToTemplateSlug: { "auth:oauth2:connection": "oauth2" }, + slotToVariable: { "auth:oauth2:connection": "token" }, + }), + ], + ]), + oauthResourceOverrides: new Map([ + ["user-org:user_U:org_X linear_mcp", "https://mcp.linear.app/mcp"], + ]), + connections: [ + { + id: "linear-oauth", + scopeId: "user-org:user_U:org_X", + provider: "workos-vault", + identityLabel: "Linear MCP OAuth", + accessTokenSecretId: "linear-access", + refreshTokenSecretId: "linear-refresh", + expiresAt: 555, + providerState: { + kind: "dynamic-dcr", + clientId: "cid-linear", + tokenEndpoint: "https://mcp.linear.app/token", + authorizationServerUrl: "https://mcp.linear.app/authorize", + resource: "https://mcp.linear.app", + }, + }, + ], + bindings: [ + { + scopeId: "user-org:user_U:org_X", + sourceId: "linear_mcp", + slotKey: "auth:oauth2:connection", + kind: "connection", + secretId: null, + connectionId: "linear-oauth", + textValue: null, + }, + ], + secrets: [ + { + id: "linear-access", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "linear-oauth", + }, + { + id: "linear-refresh", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "linear-oauth", + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.oauthClients).toHaveLength(1); + expect(plan.oauthClients[0]?.resource).toBe("https://mcp.linear.app/mcp"); + }); + + it("rewrites legacy Microsoft Graph policies only for tenants migrated to the curated slug", () => { + const curatedSlug = "microsoft_graph_v1_0_sharepoint_files_excel_outlook_combined_curated"; + const input: MigrationInput = { + nowMs: now, + sources: [ + { + scopeId: "org_CURATED", + id: curatedSlug, + pluginId: "openapi", + name: "Microsoft Graph Curated", + }, + { + scopeId: "org_LEGACY", + id: "microsoft_graph", + pluginId: "openapi", + name: "Microsoft Graph", + }, + ], + migratedConfigs: new Map([ + [`org_CURATED ${curatedSlug}`, cfg()], + ["org_LEGACY microsoft_graph", cfg()], + ]), + connections: [], + bindings: [], + secrets: [], + policies: [ + { + scopeId: "org_CURATED", + pattern: "microsoft_graph.meMessage.meDeleteMessages", + action: "block", + }, + { + scopeId: "org_LEGACY", + pattern: "microsoft_graph.meMessage.meDeleteMessages", + action: "block", + }, + ], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.policies.map((p) => p.pattern)).toEqual([ + `${curatedSlug}.*.*.meMessage.meDeleteMessages`, + "microsoft_graph.*.*.meMessage.meDeleteMessages", + ]); + expect(plan.report.policies).toEqual({ ok: 2, static: 0, deadInert: 0 }); + }); + + // The prod Microsoft shape: v1 stored only a bare issuer origin plus the + // full token endpoint (no discrete authorize endpoint — v1 discovered it at + // runtime). A bare origin is a dead authorize URL (sign-in completes but + // never redirects back), so the planner must derive the same-origin + // `…/authorize` sibling of the token endpoint instead. + it("derives the authorize endpoint from the token endpoint when v1 only stored a bare issuer", () => { + const input: MigrationInput = { + nowMs: now, + sources: [ + { scopeId: "org_X", id: "microsoft_graph", pluginId: "openapi", name: "Microsoft Graph" }, + ], + migratedConfigs: new Map([ + [ + "org_X microsoft_graph", + cfg({ + slotToTemplateSlug: { "oauth2:azureaddelegated:connection": "azureAdDelegated" }, + slotToVariable: { "oauth2:azureaddelegated:connection": "token" }, + }), + ], + ]), + connections: [ + { + id: "openapi-oauth-microsoft-graph", + scopeId: "org_X", + provider: "oauth2", + identityLabel: null, + accessTokenSecretId: "ms-access", + refreshTokenSecretId: null, + expiresAt: null, + providerState: { + kind: "authorization-code", + clientId: "cid-ms", + issuerUrl: "https://login.microsoftonline.com", + tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + scopes: ["User.Read"], + }, + }, + ], + bindings: [ + { + scopeId: "org_X", + sourceId: "microsoft_graph", + slotKey: "oauth2:azureaddelegated:connection", + kind: "connection", + secretId: null, + connectionId: "openapi-oauth-microsoft-graph", + textValue: null, + }, + ], + secrets: [ + { + id: "ms-access", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "openapi-oauth-microsoft-graph", + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.oauthClients).toHaveLength(1); + expect(plan.oauthClients[0]?.authorizationUrl).toBe( + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + ); + expect(plan.oauthClients[0]?.tokenUrl).toBe( + "https://login.microsoftonline.com/common/oauth2/v2.0/token", + ); + }); + + // v2 produces tools per connection, so a no-auth source (no credential + // bindings at all) must still get its canonical connection — template + // "none", empty item_ids — or the migrated integration is dead. + it("plans a workspace none-template connection for binding-less no-auth sources", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "context7", pluginId: "mcp", name: "Context7" }], + migratedConfigs: new Map([ + ["org_X context7", cfg({ config: { transport: "remote", auth: { kind: "none" } } })], + ]), + connections: [], + bindings: [], + secrets: [], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.connections).toHaveLength(1); + const conn = plan.connections[0]; + expect(conn?.row.integration).toBe("context7"); + expect(conn?.row.name).toBe("workspace"); + expect(conn?.row.template).toBe("none"); + expect(conn?.row.owner).toBe("org"); + expect(conn?.itemIds).toEqual({}); + // An OAuth-protected source must NOT get one. + const oauthInput: MigrationInput = { + ...input, + migratedConfigs: new Map([ + ["org_X context7", cfg({ config: { transport: "remote", auth: { kind: "oauth2" } } })], + ]), + }; + expect(planMigration(oauthInput).connections).toHaveLength(0); + }); + + it("skips secret-binding groups whose bindings reference no secrets, with a warning", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "broken_api", pluginId: "openapi", name: "Broken" }], + migratedConfigs: new Map(), + connections: [], + bindings: [ + { + scopeId: "org_X", + sourceId: "broken_api", + slotKey: "header:authorization", + kind: "secret", + secretId: null, + connectionId: null, + textValue: null, + }, + ], + secrets: [], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + // No empty-item_ids ghost connection (the runtime would refuse it tools). + expect(plan.connections).toHaveLength(0); + expect(plan.report.warnings.some((w) => w.includes("reference no secrets"))).toBe(true); + }); + + it("keys text-binding item ids by source so same-slot bindings do not collide", () => { + const binding = (sourceId: string, textValue: string) => ({ + scopeId: "org_X", + sourceId, + slotKey: "header:authorization", + kind: "text" as const, + secretId: null, + connectionId: null, + textValue, + }); + const input: MigrationInput = { + nowMs: now, + sources: [ + { scopeId: "org_X", id: "api_one", pluginId: "mcp", name: "One" }, + { scopeId: "org_X", id: "api_two", pluginId: "mcp", name: "Two" }, + ], + migratedConfigs: new Map(), + connections: [], + bindings: [binding("api_one", "value-one"), binding("api_two", "value-two")], + secrets: [], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + const ids = plan.connections.map((c) => Object.values(c.itemIds)).flat(); + expect(new Set(ids).size).toBe(2); + const ops = plan.secretOps.filter((o) => o.role === "apikey"); + expect(ops.map((o) => "fromText" in o && o.fromText).sort()).toEqual([ + "value-one", + "value-two", + ]); + }); + + it("plans a v1 client-credentials OAuth connection with secret-backed client credentials", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "dealcloud_api", pluginId: "openapi", name: "DealCloud" }], + migratedConfigs: new Map([ + [ + "org_X dealcloud_api", + cfg({ + slotToTemplateSlug: { "oauth2:dealcloudoauth:connection": "dealCloudOAuth" }, + slotToVariable: { "oauth2:dealcloudoauth:connection": "token" }, + }), + ], + ]), + connections: [ + { + id: "dealcloud-oauth", + scopeId: "org_X", + provider: "workos-vault", + identityLabel: "DealCloud API", + accessTokenSecretId: "dealcloud-access", + refreshTokenSecretId: null, + expiresAt: null, + providerState: { + kind: "client-credentials", + clientIdSecretId: "dealcloud-client-id", + clientSecretSecretId: "dealcloud-client-secret", + tokenEndpoint: "https://tenant.dealcloud.example/oauth/token", + resource: "https://api.dealcloud.com", + scopes: ["data", "reporting"], + }, + }, + ], + bindings: [ + { + scopeId: "org_X", + sourceId: "dealcloud_api", + slotKey: "oauth2:dealcloudoauth:connection", + kind: "connection", + secretId: null, + connectionId: "dealcloud-oauth", + textValue: null, + }, + ], + secrets: [ + { + id: "dealcloud-access", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "dealcloud-oauth", + }, + { + id: "dealcloud-client-id", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + { + id: "dealcloud-client-secret", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.oauthClients).toHaveLength(1); + expect(plan.oauthClients[0]).toMatchObject({ + slug: "dealcloud", + clientId: "", + clientIdSecretRef: { + scopeId: "org_X", + secretId: "dealcloud-client-id", + provider: "workos-vault", + }, + grant: "client_credentials", + tokenUrl: "https://tenant.dealcloud.example/oauth/token", + authorizationUrl: "", + resource: "https://api.dealcloud.com", + clientSecretItemId: migratedItemId("org_X", "dealcloud-client-secret"), + }); + + const connection = plan.connections[0]; + expect(connection?.row.template).toBe("dealCloudOAuth"); + expect(connection?.row.oauthClientSlug).toBe("dealcloud"); + expect(connection?.row.oauthClientOwner).toBe("org"); + expect(connection?.row.oauthScope).toBe("data reporting"); + expect(connection?.row.expiresAt).toBe(now + SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS); + expect(connection?.refreshItemId).toBeNull(); + expect(connection?.itemIds.token).toBe(migratedItemId("org_X", "dealcloud-access")); + expect(plan.secretOps.map((op) => op.role).sort()).toEqual(["client-secret", "oauth-access"]); + expect(plan.report.warnings).toEqual([]); + }); + + it("does not turn oauth client credential bindings into visible api-key connections", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "spotify_web_api", pluginId: "openapi", name: "Spotify" }], + migratedConfigs: new Map([ + [ + "org_X spotify_web_api", + cfg({ + slotToTemplateSlug: { "oauth2:oauth-2-0:connection": "oauth_2_0" }, + slotToVariable: { "oauth2:oauth-2-0:connection": "token" }, + }), + ], + ]), + connections: [ + { + id: "spotify-oauth", + scopeId: "user-org:user_U:org_X", + provider: "oauth2", + identityLabel: "Spotify Web API OAuth", + accessTokenSecretId: "spotify-access", + refreshTokenSecretId: "spotify-refresh", + expiresAt: 123, + providerState: { + kind: "authorization-code", + clientIdSecretId: "spotify-client-id", + clientSecretSecretId: "spotify-client-secret", + clientIdSecretScopeId: "org_X", + clientSecretSecretScopeId: "org_X", + tokenEndpoint: "https://accounts.spotify.com/api/token", + issuerUrl: "https://accounts.spotify.com", + scopes: ["user-read-email"], + }, + }, + ], + bindings: [ + { + scopeId: "org_X", + sourceId: "spotify_web_api", + slotKey: "oauth2:oauth-2-0:client-id", + kind: "secret", + secretId: "spotify-client-id", + connectionId: null, + textValue: null, + }, + { + scopeId: "org_X", + sourceId: "spotify_web_api", + slotKey: "oauth2:oauth-2-0:client-secret", + kind: "secret", + secretId: "spotify-client-secret", + connectionId: null, + textValue: null, + }, + { + scopeId: "user-org:user_U:org_X", + sourceScopeId: "org_X", + sourceId: "spotify_web_api", + slotKey: "oauth2:oauth-2-0:connection", + kind: "connection", + secretId: null, + connectionId: "spotify-oauth", + textValue: null, + }, + ], + secrets: [ + { + id: "spotify-access", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "spotify-oauth", + }, + { + id: "spotify-refresh", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "spotify-oauth", + }, + { + id: "spotify-client-id", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + { + id: "spotify-client-secret", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.connections).toHaveLength(1); + expect(plan.connections[0]?.row.owner).toBe("user"); + expect(plan.connections[0]?.row.template).toBe("oauth_2_0"); + expect(plan.connections[0]?.row.oauthClientSlug).toBe("spotify"); + expect(plan.connections[0]?.itemIds.token).toBe( + migratedItemId("user-org:user_U:org_X", "spotify-access"), + ); + expect(plan.connections[0]?.refreshItemId).toBe( + migratedItemId("user-org:user_U:org_X", "spotify-refresh"), + ); + expect(plan.oauthClients[0]).toMatchObject({ + slug: "spotify", + clientIdSecretRef: { + scopeId: "org_X", + secretId: "spotify-client-id", + provider: "workos-vault", + }, + clientSecretItemId: migratedItemId("org_X", "spotify-client-secret"), + }); + expect(plan.secretOps.map((op) => op.role).sort()).toEqual([ + "client-secret", + "oauth-access", + "oauth-refresh", + ]); + }); + + it("resolves legacy client credential secret ids from the source scope for personal bindings", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "dealcloud_api", pluginId: "openapi", name: "DealCloud" }], + migratedConfigs: new Map([ + [ + "org_X dealcloud_api", + cfg({ + slotToTemplateSlug: { "oauth2:dealcloudoauth:connection": "dealCloudOAuth" }, + slotToVariable: { "oauth2:dealcloudoauth:connection": "token" }, + }), + ], + ]), + connections: [ + { + id: "personal-dealcloud-oauth", + scopeId: "user-org:user_U:org_X", + provider: "workos-vault", + identityLabel: "DealCloud API", + accessTokenSecretId: "dealcloud-access", + refreshTokenSecretId: null, + expiresAt: null, + providerState: { + kind: "client-credentials", + clientIdSecretId: "dealcloud-client-id", + clientSecretSecretId: "dealcloud-client-secret", + tokenEndpoint: "https://tenant.dealcloud.example/oauth/token", + }, + }, + ], + bindings: [ + { + scopeId: "user-org:user_U:org_X", + sourceScopeId: "org_X", + sourceId: "dealcloud_api", + slotKey: "oauth2:dealcloudoauth:connection", + kind: "connection", + secretId: null, + connectionId: "personal-dealcloud-oauth", + textValue: null, + }, + ], + secrets: [ + { + id: "dealcloud-access", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "personal-dealcloud-oauth", + }, + { + id: "dealcloud-client-id", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + { + id: "dealcloud-client-secret", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.oauthClients[0]).toMatchObject({ + ownerKeys: { + owner: "user", + subject: "user_U", + tenant: "org_X", + }, + clientIdSecretRef: { + scopeId: "org_X", + secretId: "dealcloud-client-id", + provider: "workos-vault", + }, + clientSecretItemId: migratedItemId("org_X", "dealcloud-client-secret"), + }); + expect(plan.secretOps.find((op) => op.role === "client-secret")).toMatchObject({ + itemId: migratedItemId("org_X", "dealcloud-client-secret"), + fromSecret: { + scopeId: "org_X", + secretId: "dealcloud-client-secret", + provider: "workos-vault", + }, + }); + expect(plan.connections[0]?.row.owner).toBe("user"); + expect(plan.connections[0]?.itemIds.token).toBe( + migratedItemId("user-org:user_U:org_X", "dealcloud-access"), + ); + expect(plan.report.warnings).toEqual([]); + }); + + it("keeps metadata-producing secret ops for the same item id in different owner partitions", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "dealcloud_api", pluginId: "openapi", name: "DealCloud" }], + migratedConfigs: new Map([ + [ + "org_X dealcloud_api", + cfg({ + slotToTemplateSlug: { "oauth2:dealcloudoauth:connection": "dealCloudOAuth" }, + slotToVariable: { "oauth2:dealcloudoauth:connection": "token" }, + }), + ], + ]), + connections: [ + { + id: "org-dealcloud-oauth", + scopeId: "org_X", + provider: "workos-vault", + identityLabel: "DealCloud API", + accessTokenSecretId: "org-access", + refreshTokenSecretId: null, + expiresAt: null, + providerState: { + kind: "client-credentials", + clientId: "client-id", + clientSecretSecretId: "shared-client-secret", + tokenEndpoint: "https://tenant.dealcloud.example/oauth/token", + }, + }, + { + id: "personal-dealcloud-oauth", + scopeId: "user-org:user_U:org_X", + provider: "workos-vault", + identityLabel: "DealCloud API", + accessTokenSecretId: "personal-access", + refreshTokenSecretId: null, + expiresAt: null, + providerState: { + kind: "client-credentials", + clientId: "client-id", + clientSecretSecretId: "shared-client-secret", + tokenEndpoint: "https://tenant.dealcloud.example/oauth/token", + }, + }, + ], + bindings: [ + { + scopeId: "org_X", + sourceId: "dealcloud_api", + slotKey: "oauth2:dealcloudoauth:connection", + kind: "connection", + secretId: null, + connectionId: "org-dealcloud-oauth", + textValue: null, + }, + { + scopeId: "user-org:user_U:org_X", + sourceScopeId: "org_X", + sourceId: "dealcloud_api", + slotKey: "oauth2:dealcloudoauth:connection", + kind: "connection", + secretId: null, + connectionId: "personal-dealcloud-oauth", + textValue: null, + }, + ], + secrets: [ + { + id: "org-access", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "org-dealcloud-oauth", + }, + { + id: "personal-access", + scopeId: "user-org:user_U:org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: "personal-dealcloud-oauth", + }, + { + id: "shared-client-secret", + scopeId: "org_X", + name: "", + provider: "workos-vault", + ownedByConnectionId: null, + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + const clientSecretOps = plan.secretOps.filter((op) => op.role === "client-secret"); + + expect(clientSecretOps).toHaveLength(2); + expect(clientSecretOps.map((op) => op.itemId)).toEqual([ + migratedItemId("org_X", "shared-client-secret"), + migratedItemId("org_X", "shared-client-secret"), + ]); + expect(clientSecretOps.map((op) => `${op.owner.owner}:${op.owner.subject}`).sort()).toEqual([ + "org:", + "user:user_U", + ]); + expect(plan.secretOps.map((op) => op.role).sort()).toEqual([ + "client-secret", + "client-secret", + "oauth-access", + "oauth-access", + ]); + }); + + it("uses source_scope_id for templates and secret_scope_id for shared secret values", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "shared_api", pluginId: "openapi", name: "Shared API" }], + migratedConfigs: new Map([ + [ + "org_X shared_api", + cfg({ + slotToTemplateSlug: { "header:authorization": "bearer" }, + slotToVariable: { "header:authorization": "token" }, + }), + ], + ]), + connections: [], + bindings: [ + { + scopeId: "user-org:user_U:org_X", + sourceScopeId: "org_X", + sourceId: "shared_api", + slotKey: "header:authorization", + kind: "secret", + secretId: "shared-key", + secretScopeId: "org_X", + connectionId: null, + textValue: null, + }, + ], + secrets: [ + { + id: "shared-key", + scopeId: "org_X", + name: "Shared key", + provider: "workos-vault", + ownedByConnectionId: null, + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + expect(plan.integrations.map((row) => [row.slug, row.owner, row.subject])).toEqual([ + ["shared_api", "org", ""], + ]); + expect(plan.connections).toHaveLength(1); + expect(plan.connections[0]?.sourceScopeId).toBe("org_X"); + expect(plan.connections[0]?.row.name).toBe("sharedKey"); + expect(plan.connections[0]?.row.owner).toBe("user"); + expect(plan.connections[0]?.row.template).toBe("bearer"); + expect(plan.connections[0]?.itemIds.token).toBe(migratedItemId("org_X", "shared-key")); + expect(plan.secretOps[0]?.fromSecret?.scopeId).toBe("org_X"); + }); + + it("keeps the generic api-key name for multi-secret static connections", () => { + const input: MigrationInput = { + nowMs: now, + sources: [{ scopeId: "org_X", id: "datadog_api", pluginId: "openapi", name: "Datadog" }], + migratedConfigs: new Map([ + [ + "org_X datadog_api", + cfg({ + slotToTemplateSlug: { + "header:x-api-key": "apiKey", + "header:x-app-key": "apiKey", + }, + slotToVariable: { + "header:x-api-key": "apiKey", + "header:x-app-key": "appKey", + }, + }), + ], + ]), + connections: [], + bindings: [ + { + scopeId: "org_X", + sourceId: "datadog_api", + slotKey: "header:x-api-key", + kind: "secret", + secretId: "dd-api-key", + connectionId: null, + textValue: null, + }, + { + scopeId: "org_X", + sourceId: "datadog_api", + slotKey: "header:x-app-key", + kind: "secret", + secretId: "dd-app-key", + connectionId: null, + textValue: null, + }, + ], + secrets: [ + { + id: "dd-api-key", + scopeId: "org_X", + name: "Datadog API key", + provider: "workos-vault", + ownedByConnectionId: null, + }, + { + id: "dd-app-key", + scopeId: "org_X", + name: "Datadog app key", + provider: "workos-vault", + ownedByConnectionId: null, + }, + ], + policies: [], + toolSourceIds: [], + }; + + const plan = planMigration(input); + + expect(plan.connections).toHaveLength(1); + expect(plan.connections[0]?.row.name).toBe("api-key"); + expect(plan.connections[0]?.itemIds).toEqual({ + apiKey: migratedItemId("org_X", "dd-api-key"), + appKey: migratedItemId("org_X", "dd-app-key"), + }); + }); +}); diff --git a/packages/core/sdk/src/migration-spec.ts b/packages/core/sdk/src/migration-spec.ts new file mode 100644 index 000000000..3b16dc3a8 --- /dev/null +++ b/packages/core/sdk/src/migration-spec.ts @@ -0,0 +1,1812 @@ +// --------------------------------------------------------------------------- +// v1.4.x → v2 migration — pure transform building blocks. +// +// These are the schema-STABLE, side-effect-free pieces of the migration: the +// scope→owner split, the policy-pattern remap, the WorkOS-Vault object naming +// (v1 read name vs v2 write name), the oauth_client dedup key, and OAuth scope +// serialization. The cloud/local RUNNERS read old rows + resolve secret values +// and call these; keeping them pure makes the risky part unit-testable without +// a database. See `personal-notes/migration-notes-5.md` for the full design. +// --------------------------------------------------------------------------- + +import { createHash } from "node:crypto"; + +import { connectionIdentifier } from "./connection-name-identifier"; + +export { + migrationOAuthAuthorizationUrlFor, + migrationOAuthClientAuthorizationUrlResolutionSource, + migrationOAuthClientNeedsAuthorizationUrlResolution, + migrationOAuthClientPlanKey, + resolveMigrationOAuthAuthorizationUrls, + type MigrationOAuthMetadataFetch, + type ResolveMigrationOAuthAuthorizationUrlsOptions, +} from "./migration-oauth-metadata"; + +export type MigrationOwner = "org" | "user"; + +/** v2 owner partition for a migrated row. `subject` is "" (ORG_SUBJECT) for org. */ +export interface OwnerKeys { + readonly owner: MigrationOwner; + readonly subject: string; + readonly tenant: string; +} + +const ORG_SUBJECT = ""; + +// --------------------------------------------------------------------------- +// Scope → (owner, subject, tenant) +// +// v1 scope ids are EXACTLY two shapes in prod (verified across 1,079 rows): +// - `org_` → org-owned (shared); tenant = the org id +// - `user-org:user_:org_` → a user's personal scope within an org +// Returns null for any other shape so the runner FAILS LOUD rather than +// silently mis-owning a row (a scope leak is a security bug, not a data bug). +// --------------------------------------------------------------------------- + +export const parseScope = (scopeId: string): OwnerKeys | null => { + if (scopeId.startsWith("user-org:")) { + const parts = scopeId.split(":"); + if (parts.length !== 3) return null; + const [, user, org] = parts; + if (!user || !org || !user.startsWith("user_") || !org.startsWith("org_")) return null; + return { owner: "user", subject: user, tenant: org }; + } + if (scopeId.startsWith("org_") && !scopeId.includes(":")) { + return { owner: "org", subject: ORG_SUBJECT, tenant: scopeId }; + } + return null; +}; + +export type ScopeOwnerResolver = (scopeId: string) => OwnerKeys | null; + +/** Stable string identifying the partition an oauth_client is deduped within — + * shared org apps collapse across the org, personal apps stay per-user. */ +export const ownerPartitionKey = (keys: OwnerKeys): string => + keys.owner === "org" ? `org:${keys.tenant}` : `user:${keys.subject}:${keys.tenant}`; + +// --------------------------------------------------------------------------- +// WorkOS Vault object naming. +// +// v1 stored a secret value at `executor//secrets/` (with a +// per-scope KEK), url-encoding the segments — plus a LEGACY un-encoded fallback. +// v2 drops the scope segment (flat KEK): `executor/secrets/`. The names +// differ, so id-reuse is impossible — the runner reads the v1 object and +// re-writes a fresh v2 object (+ a `plugin_storage[metadata]` row). +// --------------------------------------------------------------------------- + +export const DEFAULT_VAULT_PREFIX = "executor"; +const enc = encodeURIComponent; + +export const vaultV1ObjectName = (prefix: string, scopeId: string, secretId: string): string => + `${prefix}/${enc(scopeId)}/secrets/${enc(secretId)}`; + +/** The legacy un-url-encoded variant v1 falls back to on a 404. */ +export const vaultV1LegacyObjectName = ( + prefix: string, + scopeId: string, + secretId: string, +): string => `${prefix}/${scopeId}/secrets/${secretId}`; + +export const vaultV2ObjectName = (prefix: string, itemId: string): string => + `${prefix}/secrets/${enc(itemId)}`; + +// --------------------------------------------------------------------------- +// oauth_client dedup key — collapse identical apps (BYO means mostly distinct, +// so this only merges a handful). Keyed on the owner partition + client id + +// token endpoint. NUL-separated so no value can forge a collision. +// --------------------------------------------------------------------------- + +export const oauthClientDedupKey = ( + partition: string, + clientId: string, + tokenEndpoint: string, +): string => `${partition} ${clientId} ${tokenEndpoint}`; + +// --------------------------------------------------------------------------- +// OAuth scope serialization — v1 `provider_state.scopes` is a JSON array; +// v2 `connection.oauth_scope` is a single space-joined string (round-trips: +// split on `\s+` at refresh). Order-preserving de-dupe; space-join is lossless +// because no scope value contains a space. +// --------------------------------------------------------------------------- + +export const serializeOAuthScopes = (scopes: readonly string[]): string => + [...new Set(scopes.filter((s) => s.length > 0))].join(" "); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// --------------------------------------------------------------------------- +// Policy pattern migration. +// +// v1 patterns match a connection-AGNOSTIC `.` id. v2 matches the +// FULL `...`, so a migrated policy applies +// across ALL connections → wildcard the owner+connection segments. The +// whole-integration (`.*`) and bare-slug forms already cover the deeper +// segments via their trailing `*`, so they only get the slug remapped. +// +// - `*` → `*` (universal) +// - static ns (`executor.*`) → unchanged (pass-through) +// - `` / `.*` → `` / `.*` (subtree) +// - `.` → `.*.*.` (insert wildcards) +// - unknown first segment → DEAD (source removed) — flag, never silently drop +// --------------------------------------------------------------------------- + +export const DEFAULT_STATIC_NAMESPACES: readonly string[] = ["executor", "openapi"]; + +const MICROSOFT_GRAPH_LEGACY_SLUG = "microsoft_graph"; +const MICROSOFT_GRAPH_CURATED_SLUG = + "microsoft_graph_v1_0_sharepoint_files_excel_outlook_combined_curated"; + +export type PolicyTransformResult = + | { readonly kind: "ok"; readonly pattern: string } + | { readonly kind: "static"; readonly pattern: string } + | { readonly kind: "dead"; readonly slug: string }; + +export const migratePolicyPattern = ( + pattern: string, + slugMap: ReadonlyMap, + staticNamespaces: readonly string[] = DEFAULT_STATIC_NAMESPACES, +): PolicyTransformResult => { + if (pattern === "*") return { kind: "ok", pattern: "*" }; + const firstDot = pattern.indexOf("."); + const slug = firstDot === -1 ? pattern : pattern.slice(0, firstDot); + if (staticNamespaces.includes(slug)) return { kind: "static", pattern }; + const newSlug = slugMap.get(slug); + if (newSlug === undefined) return { kind: "dead", slug }; + const rest = firstDot === -1 ? "" : pattern.slice(firstDot + 1); + if (rest === "") return { kind: "ok", pattern: newSlug }; + if (rest === "*") return { kind: "ok", pattern: `${newSlug}.*` }; + return { kind: "ok", pattern: `${newSlug}.*.*.${rest}` }; +}; + +// --------------------------------------------------------------------------- +// Plugin runtime metadata migration. +// +// v1 plugins persisted invocation metadata in plugin-specific storage alongside +// source rows. v2 puts catalog-level operation metadata under org-owned +// `plugin_storage[operation]`, and MCP stores the raw upstream tool name on the +// tool row annotations. These helpers are shared by local + cloud runners so the +// migration produces the same v2-native runtime shape everywhere. +// --------------------------------------------------------------------------- + +const GRAPHQL_GREENFIELD_V1_PLUGIN_ID = "graphql-greenfield"; +const GRAPHQL_V2_PLUGIN_ID = "graphql"; +const OPENAPI_PLUGIN_ID = "openapi"; +const MCP_PLUGIN_ID = "mcp"; +const OPERATION_COLLECTION = "operation"; +const MCP_BINDING_COLLECTION = "binding"; + +const normalizeRuntimePluginId = (pluginId: string): string => + pluginId === GRAPHQL_GREENFIELD_V1_PLUGIN_ID ? GRAPHQL_V2_PLUGIN_ID : pluginId; + +export interface V1ToolRuntimeMetadataRow { + readonly scopeId: string; + readonly sourceId: string; + readonly pluginId: string; + readonly name: string; + readonly annotations: unknown; +} + +export interface V1PluginStorageRuntimeRow { + readonly scopeId: string; + readonly pluginId: string; + readonly collection: string; + readonly key: string; + readonly data: unknown; +} + +export interface LegacyMcpToolBinding { + readonly toolName: string; + readonly annotations?: Record; +} + +export interface V1RuntimeMetadataIndex { + readonly mcpBindings: ReadonlyMap; +} + +export type MigratedPluginStorageOwner = "source" | "catalog"; + +export interface MigratedPluginStorageRuntimeRow { + readonly pluginId: string; + readonly collection: string; + readonly key: string; + readonly data: unknown; + /** `catalog` rows are v2 integration metadata and must be org-owned. */ + readonly owner: MigratedPluginStorageOwner; +} + +const runtimeStorageKey = (scopeId: string, key: string): string => `${scopeId}\0${key}`; + +const fullToolKey = (sourceId: string, toolName: string): string => `${sourceId}.${toolName}`; + +const stripToolPrefix = (sourceId: string, toolId: string): string => + toolId.startsWith(`${sourceId}.`) ? toolId.slice(sourceId.length + 1) : toolId; + +const legacyMcpToolBinding = (data: unknown): LegacyMcpToolBinding | null => { + if (!isRecord(data) || !isRecord(data.binding)) return null; + const toolName = data.binding.toolName; + if (typeof toolName !== "string" || toolName.length === 0) return null; + const annotations = isRecord(data.binding.annotations) ? data.binding.annotations : undefined; + return { + toolName, + ...(annotations ? { annotations } : {}), + }; +}; + +export const buildV1RuntimeMetadataIndex = ( + rows: readonly V1PluginStorageRuntimeRow[], +): V1RuntimeMetadataIndex => { + const mcpBindings = new Map(); + for (const row of rows) { + if (normalizeRuntimePluginId(row.pluginId) !== MCP_PLUGIN_ID) continue; + if (row.collection !== MCP_BINDING_COLLECTION) continue; + const binding = legacyMcpToolBinding(row.data); + if (!binding) continue; + mcpBindings.set(runtimeStorageKey(row.scopeId, row.key), binding); + } + return { mcpBindings }; +}; + +export const migrateV1ToolAnnotations = ( + tool: V1ToolRuntimeMetadataRow, + index: V1RuntimeMetadataIndex, +): unknown => { + if (normalizeRuntimePluginId(tool.pluginId) !== MCP_PLUGIN_ID) return tool.annotations; + if (isRecord(tool.annotations) && isRecord(tool.annotations.mcp)) return tool.annotations; + + const binding = index.mcpBindings.get( + runtimeStorageKey(tool.scopeId, fullToolKey(tool.sourceId, tool.name)), + ); + if (!binding) return tool.annotations; + + const base = isRecord(tool.annotations) ? tool.annotations : {}; + const destructive = binding.annotations?.destructiveHint === true; + return { + ...base, + requiresApproval: base.requiresApproval ?? destructive, + ...(destructive && base.approvalDescription === undefined + ? { approvalDescription: binding.annotations?.title ?? binding.toolName } + : {}), + mcp: { + toolName: binding.toolName, + ...(binding.annotations ? { upstream: binding.annotations } : {}), + }, + }; +}; + +const migrateOperationStorageRow = ( + row: V1PluginStorageRuntimeRow, +): MigratedPluginStorageRuntimeRow | null => { + const pluginId = normalizeRuntimePluginId(row.pluginId); + if ( + row.collection !== OPERATION_COLLECTION || + (pluginId !== OPENAPI_PLUGIN_ID && pluginId !== GRAPHQL_V2_PLUGIN_ID) + ) { + return null; + } + if (!isRecord(row.data)) return null; + + const existingIntegration = row.data.integration; + const existingToolName = row.data.toolName; + if ( + typeof existingIntegration === "string" && + typeof existingToolName === "string" && + "binding" in row.data + ) { + return { + pluginId, + collection: row.collection, + key: fullToolKey(existingIntegration, existingToolName), + data: { + integration: existingIntegration, + toolName: existingToolName, + binding: row.data.binding, + }, + owner: "catalog", + }; + } + + const sourceId = row.data.sourceId; + const toolId = row.data.toolId; + if (typeof sourceId !== "string" || typeof toolId !== "string" || !("binding" in row.data)) { + return null; + } + + const toolName = stripToolPrefix(sourceId, toolId); + return { + pluginId, + collection: row.collection, + key: fullToolKey(sourceId, toolName), + data: { + integration: sourceId, + toolName, + binding: row.data.binding, + }, + owner: "catalog", + }; +}; + +export const migrateV1PluginStorageRuntimeRow = ( + row: V1PluginStorageRuntimeRow, +): MigratedPluginStorageRuntimeRow => { + const operation = migrateOperationStorageRow(row); + if (operation) return operation; + return { + pluginId: normalizeRuntimePluginId(row.pluginId), + collection: row.collection, + key: row.key, + data: row.data, + owner: "source", + }; +}; + +// --------------------------------------------------------------------------- +// OpenAPI auth template migration — v1 source `config` auth → v2 +// `authenticationTemplate` (Authentication[]) + the static header/query +// passthrough + the slot→method-slug map the connection pass needs. +// +// v1 shapes (verified against prod): +// - `config.headers` / `config.queryParams`: `Record` +// where a value is either a literal string (a STATIC header) or a credential +// placement `{ kind, slot?, prefix? }`. v1 applied EVERY configured credential +// placement on every request, rendering `prefix ? prefix+value : value`. +// - `config.oauth2`: the user-configured OAuth method (real urls/scopes/scheme). +// +// v2 model: each connection holds ONE value, rendered ONLY into the `token` +// variable (any other variable name renders to ""). So a source with a SINGLE +// credential placement maps cleanly to one apiKey method `{name: [prefix, token]}`; +// a source with >1 distinct credential placement (Datadog dd-api-key + +// dd-application-key; GitHub authorization + user-agent) has no faithful +// single-connection v2 form — we flag `needsReview` and never silently emit a +// template that would render a second credential as "". +// --------------------------------------------------------------------------- + +/** A v1 `config.headers` / `config.queryParams` entry. A bare string is a static + * value; the object forms are credential placements that pull from a binding / + * secret / inline text, optionally with a literal `prefix` (e.g. `"Bearer "`). */ +export type V1ConfiguredValue = + | string + | { readonly kind: "binding"; readonly slot: string; readonly prefix?: string } + | { readonly kind: "secret"; readonly secretId?: string; readonly prefix?: string } + | { readonly kind: "text"; readonly text: string; readonly prefix?: string }; + +export interface V1OAuth2Config { + readonly securitySchemeName: string; + readonly flow?: string; + readonly authorizationUrl?: string; + readonly tokenUrl: string; + readonly scopes?: readonly string[]; +} + +export interface V1OpenApiAuthConfig { + readonly headers?: Record; + readonly queryParams?: Record; + readonly oauth2?: V1OAuth2Config; +} + +/** A part-array template value: `prefix ? [prefix, token] : [token]`. */ +export type TemplatePart = string | { readonly type: "variable"; readonly name: string }; + +export interface MigratedApiKeyAuth { + readonly slug: string; + readonly type: "apiKey"; + readonly headers?: Record; + readonly queryParams?: Record; +} + +export interface MigratedOAuthAuth { + readonly slug: string; + readonly type: "oauth"; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly scopes: readonly string[]; +} + +export type MigratedAuthentication = MigratedApiKeyAuth | MigratedOAuthAuth; + +export interface OpenApiAuthTemplateResult { + readonly authenticationTemplate: readonly MigratedAuthentication[]; + /** Literal-string header/query entries — passed through to v2 `config`. */ + readonly staticHeaders: Record; + readonly staticQueryParams: Record; + /** v1 binding `slot_key` → the v2 method `slug` that consumes it. The + * connection pass uses this to set `connection.template`. */ + readonly slotToTemplateSlug: Record; + /** v1 binding `slot_key` → the v2 input variable its resolved secret fills. The + * connection migration writes one `item_ids` entry per (variable → secret), so + * a two-secret source (e.g. Datadog) lands both keys on one connection. */ + readonly slotToVariable: Record; + readonly warnings: readonly string[]; +} + +/** The single apiKey method slug — one apiKey method per source, so a constant + * is unique within the integration and every credential slot maps to it. */ +export const API_KEY_TEMPLATE_SLUG = "apiKey"; + +/** The canonical variable for a single-input source. Multiple inputs derive + * distinct names instead (so a connection can carry both of e.g. Datadog's + * keys). Matches the runtime default in `connection.ts` / the plugins. */ +export const PRIMARY_INPUT_VARIABLE = "token"; + +const isCredentialPlacement = ( + value: V1ConfiguredValue, +): value is Exclude => typeof value !== "string"; + +/** Slugify a header/query name into a stable variable identifier: + * `DD-API-KEY` → `dd_api_key`. */ +const slugifyVariable = (name: string): string => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + +interface Placement { + readonly carrier: "header" | "query"; + readonly name: string; + readonly prefix?: string; + readonly slot?: string; +} + +const legacyOAuthSlotSchemeVariants = (securitySchemeName: string): readonly string[] => { + const lower = securitySchemeName.toLowerCase(); + const hyphenated = lower.replaceAll("_", "-"); + return [...new Set([lower, hyphenated])]; +}; + +export const migrateOpenApiAuthTemplate = ( + config: V1OpenApiAuthConfig, +): OpenApiAuthTemplateResult => { + const template: MigratedAuthentication[] = []; + const staticHeaders: Record = {}; + const staticQueryParams: Record = {}; + const slotToTemplateSlug: Record = {}; + const slotToVariable: Record = {}; + const warnings: string[] = []; + + // First pass — separate credential placements from static literals. + const placements: Placement[] = []; + for (const [name, value] of Object.entries(config.headers ?? {})) { + if (isCredentialPlacement(value)) { + placements.push({ + carrier: "header", + name, + prefix: value.prefix, + slot: value.kind === "binding" ? value.slot : undefined, + }); + } else { + staticHeaders[name] = value; + } + } + for (const [name, value] of Object.entries(config.queryParams ?? {})) { + if (isCredentialPlacement(value)) { + placements.push({ + carrier: "query", + name, + prefix: value.prefix, + slot: value.kind === "binding" ? value.slot : undefined, + }); + } else { + staticQueryParams[name] = value; + } + } + + // Variable per placement: a lone input is the canonical `token`; multiple + // inputs each get a distinct slugified variable (collision-suffixed) so a + // connection carries one value per key. + const taken = new Set(); + const variableFor = (placement: Placement): string => { + if (placements.length <= 1) return PRIMARY_INPUT_VARIABLE; + const base = slugifyVariable(placement.name) || "input"; + let candidate = base; + let n = 2; + while (taken.has(candidate)) candidate = `${base}_${n++}`; + taken.add(candidate); + return candidate; + }; + + const apiKeyHeaders: Record = {}; + const apiKeyQueryParams: Record = {}; + for (const placement of placements) { + const variable = variableFor(placement); + const part: TemplatePart = { type: "variable", name: variable }; + const parts: readonly TemplatePart[] = + placement.prefix && placement.prefix.length > 0 ? [placement.prefix, part] : [part]; + if (placement.carrier === "header") apiKeyHeaders[placement.name] = parts; + else apiKeyQueryParams[placement.name] = parts; + if (placement.slot) { + slotToTemplateSlug[placement.slot] = API_KEY_TEMPLATE_SLUG; + slotToVariable[placement.slot] = variable; + } + } + + if (placements.length > 0) { + template.push({ + slug: API_KEY_TEMPLATE_SLUG, + type: "apiKey", + ...(Object.keys(apiKeyHeaders).length > 0 ? { headers: apiKeyHeaders } : {}), + ...(Object.keys(apiKeyQueryParams).length > 0 ? { queryParams: apiKeyQueryParams } : {}), + }); + } + + if (config.oauth2) { + const o = config.oauth2; + template.push({ + slug: o.securitySchemeName, + type: "oauth", + authorizationUrl: o.authorizationUrl ?? "", + tokenUrl: o.tokenUrl, + scopes: o.scopes ?? [], + }); + // The oauth connection slot binds to the oauth method, not the apiKey one; + // OAuth is single-input, so its value fills the `token` variable. + for (const scheme of legacyOAuthSlotSchemeVariants(o.securitySchemeName)) { + const slot = `oauth2:${scheme}:connection`; + slotToTemplateSlug[slot] = o.securitySchemeName; + slotToVariable[slot] = PRIMARY_INPUT_VARIABLE; + } + } + + return { + authenticationTemplate: template, + staticHeaders, + staticQueryParams, + slotToTemplateSlug, + slotToVariable, + warnings, + }; +}; + +// --------------------------------------------------------------------------- +// OAuth grant mapping + C1a synthetic expiry. +// +// v1 `connection.provider_state.kind` is authorization-code / dynamic-dcr / +// client-credentials; v2 collapses to two grants. A client_credentials token +// re-mints with NO user, so a v1 connection whose provider omitted `expires_in` +// (null v1 expiry) gets a short synthetic `expires_at` at migrate time — purely +// so the v2 refresh gate fires and re-mints, NOT a general TTL policy. A +// connection that carried a real v1 expiry keeps it. +// --------------------------------------------------------------------------- + +export type V1ConnectionKind = "authorization-code" | "dynamic-dcr" | "client-credentials"; +export type MigrationGrant = "authorization_code" | "client_credentials"; + +export const migrateGrant = (kind: V1ConnectionKind): MigrationGrant => + kind === "client-credentials" ? "client_credentials" : "authorization_code"; + +/** 1h — synthetic TTL for client_credentials connections whose v1 provider + * omitted `expires_in`. Re-mint is userless + cheap, so hourly churn is fine. */ +export const SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS = 60 * 60 * 1000; + +export const migrateExpiresAt = (input: { + readonly grant: MigrationGrant; + readonly v1ExpiresAt: number | null; + readonly nowMs: number; +}): number | null => + input.grant === "client_credentials" && input.v1ExpiresAt == null + ? input.nowMs + SYNTHETIC_CLIENT_CREDENTIALS_TTL_MS + : input.v1ExpiresAt; + +// --------------------------------------------------------------------------- +// Source config → v2 integration config. +// +// v1 per-kind config lives in `plugin_storage[collection=source].data` (openapi +// + mcp nest under `.data.config`; graphql is flat at `.data`). Structural fields +// copy near-1:1; the only real conversion is v1 auth → v2 auth. openapi reuses +// `migrateOpenApiAuthTemplate` (the static headers/queryParams + the template); +// mcp/graphql carry only an `auth.kind` (none/oauth2) — the connection slot is +// dropped (a v2 connection IS the credential). `namespace` is dropped (the +// integration slug replaces it). +// --------------------------------------------------------------------------- + +export interface V1OpenApiSourceConfig extends V1OpenApiAuthConfig { + readonly spec?: string; + readonly sourceUrl?: string; + readonly baseUrl?: string; + readonly googleDiscoveryUrls?: readonly string[]; +} + +export interface V2OpenApiIntegrationConfig { + readonly spec?: string; + readonly sourceUrl?: string; + readonly baseUrl?: string; + readonly googleDiscoveryUrls?: readonly string[]; + readonly headers?: Record; + readonly queryParams?: Record; + readonly authenticationTemplate?: readonly MigratedAuthentication[]; +} + +export interface MigratedSourceConfig { + /** The opaque v2 `integration.config` blob (openapi/mcp/graphql shaped). */ + readonly config: unknown; + /** v1 binding `slot_key` → the v2 method slug it feeds (connection migration). */ + readonly slotToTemplateSlug: Record; + /** v1 binding `slot_key` → the v2 input variable its secret fills. */ + readonly slotToVariable: Record; + readonly warnings: readonly string[]; +} + +export const migrateOpenApiSourceConfig = (v1: V1OpenApiSourceConfig): MigratedSourceConfig => { + const auth = migrateOpenApiAuthTemplate(v1); + const config: V2OpenApiIntegrationConfig = { + ...(v1.spec !== undefined ? { spec: v1.spec } : {}), + ...(v1.sourceUrl !== undefined ? { sourceUrl: v1.sourceUrl } : {}), + ...(v1.baseUrl !== undefined ? { baseUrl: v1.baseUrl } : {}), + ...(v1.googleDiscoveryUrls !== undefined + ? { googleDiscoveryUrls: v1.googleDiscoveryUrls } + : {}), + ...(Object.keys(auth.staticHeaders).length > 0 ? { headers: auth.staticHeaders } : {}), + ...(Object.keys(auth.staticQueryParams).length > 0 + ? { queryParams: auth.staticQueryParams } + : {}), + ...(auth.authenticationTemplate.length > 0 + ? { authenticationTemplate: auth.authenticationTemplate } + : {}), + }; + return { + config, + slotToTemplateSlug: auth.slotToTemplateSlug, + slotToVariable: auth.slotToVariable, + warnings: auth.warnings, + }; +}; + +/** v1 mcp/graphql `auth` block. The oauth2 form carries a `connectionSlot` that + * bound a credential_binding; v2 drops it (the connection is the credential). */ +export type V1SourceAuth = + | { readonly kind: "none" } + | { readonly kind: "oauth2"; readonly connectionSlot?: string }; + +export type V2SourceAuth = { readonly kind: "none" } | { readonly kind: "oauth2" }; + +export const migrateSourceAuth = (auth: V1SourceAuth | undefined): V2SourceAuth => + auth?.kind === "oauth2" ? { kind: "oauth2" } : { kind: "none" }; + +// --------------------------------------------------------------------------- +// Secret-role classification. +// +// Each v1 secret resolves to a v2 target keyed by HOW it is referenced. The +// runner builds the reference graph (connection token columns, provider_state +// client creds, credential_binding slots) and asks the classifier per secret. +// Roles map to: oauth-access/apikey → `connection.item_id`; oauth-refresh → +// `connection.refresh_item_id`; client-secret → `oauth_client.client_secret_item_id`; +// client-id → `oauth_client.client_id` (a PLAINTEXT column, not a vault item); +// orphan → a standalone re-keyed vault object (migrate-all default). +// --------------------------------------------------------------------------- + +export type SecretRole = + | "oauth-access" + | "oauth-refresh" + | "apikey" + | "client-secret" + | "client-id" + | "orphan"; + +/** Classify a credential_binding `slot_key` into its v2 secret role. v1 slot + * shapes: `header:` / `query_param:` / `spec_fetch_header:` + * (api key), `oauth2::client-secret` / `:client-id` (BYO client creds), + * `oauth2::connection` / `auth:oauth2:connection` (the oauth token — a + * `kind=connection` binding, not a secret; classified oauth-access for the rare + * cases it is secret-backed). */ +export const classifyBindingSlot = (slotKey: string): SecretRole => { + if (slotKey.includes("client-secret")) return "client-secret"; + if (slotKey.includes("client-id")) return "client-id"; + if ( + slotKey.startsWith("header:") || + slotKey.startsWith("query_param:") || + slotKey.startsWith("spec_fetch_header:") + ) { + return "apikey"; + } + if (slotKey.endsWith(":connection")) return "oauth-access"; + return "apikey"; +}; + +const isOAuthClientCredentialSlot = (slotKey: string): boolean => + slotKey.startsWith("oauth2:") && + (slotKey.endsWith(":client-id") || slotKey.endsWith(":client-secret")); + +// --------------------------------------------------------------------------- +// oauth_client dedup (190 → 173). +// +// v1 BYO apps are mostly distinct, but identical apps within an owner partition +// collapse to one v2 `oauth_client`. When v1 already has a plaintext client ID, +// the dedup key is `(owner-partition, clientId, tokenEndpoint)`. Older rows can +// store the client ID as a secret; those dedupe by the source secret reference +// until the runner resolves the plaintext value for `oauth_client.client_id`. +// The assigned slug is derived from the token endpoint host, collision-suffixed +// WITHIN the partition, and deterministic given input order — so a re-run +// produces the same slugs. Each connection later points at its client by +// `(slug, owner)`. +// --------------------------------------------------------------------------- + +export interface SecretReadRef { + readonly scopeId: string; + readonly secretId: string; + readonly provider: string; +} + +/** A v1 OAuth app to fold into the v2 `oauth_client` set. `clientIdSecretRef` + * carries legacy client IDs that v1 stored as secrets; runners resolve it into + * the v2 plaintext `oauth_client.client_id` column. `clientSecretRef` is an + * opaque handle (e.g. the v1 secret id/scope pair) the runner resolves to the + * actual secret value when writing the vault item; null for public/PKCE apps. */ +export interface PlannedOAuthClientInput { + readonly ownerKeys: OwnerKeys; + readonly clientId: string; + readonly clientIdSecretRef?: SecretReadRef | null; + readonly tokenUrl: string; + readonly authorizationUrl: string; + readonly authorizationServerMetadataUrl?: string | null; + readonly grant: MigrationGrant; + readonly resource: string | null; + readonly clientSecretRef: string | null; +} + +export interface DedupedOAuthClient extends PlannedOAuthClientInput { + readonly slug: string; +} + +export interface OAuthClientDedupResult { + readonly clients: readonly DedupedOAuthClient[]; + /** dedup key → assigned slug; the connection planner maps each v1 app to it. */ + readonly slugByDedupKey: Record; +} + +const hostSlug = (url: string): string => { + // Parse defensively — a malformed URL falls back to a generic stem. + const match = /^[a-z]+:\/\/([^/:]+)/i.exec(url); + const host = match?.[1] ?? "client"; + const label = host.split(".").length > 1 ? host.split(".").slice(-2, -1)[0] : host; + return slugifyVariable(label ?? "client") || "client"; +}; + +export const dedupeOAuthClients = ( + inputs: readonly PlannedOAuthClientInput[], +): OAuthClientDedupResult => { + const slugByDedupKey: Record = {}; + const clients: DedupedOAuthClient[] = []; + // Per-partition taken slugs, so two distinct apps in one partition disambiguate. + const takenByPartition = new Map>(); + + for (const input of inputs) { + const partition = ownerPartitionKey(input.ownerKeys); + const key = oauthClientPlanDedupKey(input); + if (slugByDedupKey[key]) continue; // already folded — identical app + + const taken = takenByPartition.get(partition) ?? new Set(); + const base = hostSlug(input.tokenUrl); + let slug = base; + let n = 2; + while (taken.has(slug)) slug = `${base}_${n++}`; + taken.add(slug); + takenByPartition.set(partition, taken); + + slugByDedupKey[key] = slug; + clients.push({ ...input, slug }); + } + + return { clients, slugByDedupKey }; +}; + +const secretReadRefKey = (ref: SecretReadRef): string => + `${ref.provider}\0${ref.scopeId}\0${ref.secretId}`; + +const oauthClientDedupeIdentity = (input: { + readonly clientId: string; + readonly clientIdSecretRef?: SecretReadRef | null; +}): string => + input.clientId.length > 0 + ? `literal:${input.clientId}` + : input.clientIdSecretRef + ? `secret:${secretReadRefKey(input.clientIdSecretRef)}` + : "missing:"; + +const oauthClientPlanDedupKey = (input: { + readonly ownerKeys: OwnerKeys; + readonly clientId: string; + readonly clientIdSecretRef?: SecretReadRef | null; + readonly tokenUrl: string; +}): string => + oauthClientDedupKey( + ownerPartitionKey(input.ownerKeys), + oauthClientDedupeIdentity(input), + input.tokenUrl, + ); + +/** The dedup key for a v1 app — call with the same parts to look its slug up in + * `slugByDedupKey` when planning the connection that uses it. Secret-backed v1 + * client IDs use their source secret reference so distinct BYO apps do not + * collapse before the runner can resolve plaintext values. */ +export const oauthClientSlugKey = (input: { + readonly ownerKeys: OwnerKeys; + readonly clientId: string; + readonly clientIdSecretRef?: SecretReadRef | null; + readonly tokenUrl: string; +}): string => oauthClientPlanDedupKey(input); + +// --------------------------------------------------------------------------- +// Integration + connection row assembly (the v2 rows, minus resolved secret +// values — the runner fills `item_ids`/`client_secret` from its secret-op pass). +// --------------------------------------------------------------------------- + +export interface PlannedIntegrationRow { + readonly tenant: string; + readonly owner: MigrationOwner; + readonly subject: string; + readonly slug: string; + readonly plugin_id: string; + readonly description: string; + readonly config: unknown; +} + +/** Build a v2 `integration` row from a v1 source + its migrated config. All prod + * sources are org-owned, but we derive owner from the scope generically (a local + * user-scoped source maps to owner=user). */ +export const planIntegrationRow = (input: { + readonly scopeId: string; + readonly sourceId: string; + readonly pluginId: string; + readonly description: string; + readonly config: unknown; + readonly ownerForScope?: ScopeOwnerResolver; +}): PlannedIntegrationRow | null => { + const keys = (input.ownerForScope ?? parseScope)(input.scopeId); + if (!keys) return null; // fail loud upstream — never silently mis-own + return { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + slug: input.sourceId, + plugin_id: input.pluginId, + description: input.description, + config: input.config, + }; +}; + +export interface PlannedConnectionRow { + readonly tenant: string; + readonly owner: MigrationOwner; + readonly subject: string; + readonly integration: string; + readonly name: string; + readonly template: string; + readonly provider: string; + readonly identityLabel: string | null; + readonly oauthClientSlug: string | null; + readonly oauthClientOwner: MigrationOwner | null; + readonly oauthScope: string | null; + readonly expiresAt: number | null; +} + +/** Build a v2 `connection` row (owner split, C1a expiry, oauth-client ref). The + * runner attaches `item_ids`/`refresh_item_id` from its secret-op results. */ +export const planConnectionRow = (input: { + readonly scopeId: string; + readonly integration: string; + readonly name: string; + readonly template: string; + readonly provider: string; + readonly identityLabel: string | null; + readonly grant: MigrationGrant; + readonly v1ExpiresAt: number | null; + readonly oauthScopes: readonly string[]; + readonly oauthClientSlug: string | null; + readonly oauthClientOwner: OwnerKeys | null; + readonly nowMs: number; + readonly ownerForScope?: ScopeOwnerResolver; +}): PlannedConnectionRow | null => { + const keys = (input.ownerForScope ?? parseScope)(input.scopeId); + if (!keys) return null; + return { + tenant: keys.tenant, + owner: keys.owner, + subject: keys.subject, + integration: input.integration, + name: input.name, + template: input.template, + provider: input.provider, + identityLabel: input.identityLabel, + oauthClientSlug: input.oauthClientSlug, + oauthClientOwner: input.oauthClientOwner ? input.oauthClientOwner.owner : null, + oauthScope: input.oauthScopes.length > 0 ? serializeOAuthScopes(input.oauthScopes) : null, + expiresAt: migrateExpiresAt({ + grant: input.grant, + v1ExpiresAt: input.v1ExpiresAt, + nowMs: input.nowMs, + }), + }; +}; + +// --------------------------------------------------------------------------- +// Deterministic v2 item id. +// +// A v2 vault item id is derived from the v1 `(scopeId, secretId)` pair (NOT +// random) so a crashed/re-run migration produces the SAME id and the runner's +// `provider.get(item_id)` skip-if-present idempotency holds. Distinct from any +// v1 name, so it never collides with a not-yet-migrated object. The id carries +// no legacy scope/secret names; the prefix only gives provider lists a stable +// shape. +// --------------------------------------------------------------------------- + +const stableMigrationHash = (...parts: readonly string[]): string => { + const hash = createHash("sha256"); + for (const part of parts) hash.update(part).update("\0"); + return hash.digest("base64url"); +}; + +export const migratedItemId = (scopeId: string, secretId: string): string => + `secret_${stableMigrationHash(scopeId, secretId)}`; + +const fallbackPolicyId = (scopeId: string, pattern: string, action: string): string => + `policy_${stableMigrationHash(scopeId, pattern, action)}`; + +// --------------------------------------------------------------------------- +// mcp / graphql source config → v2 integration config. +// +// Verified against prod: mcp/graphql carry the SAME `{kind:"binding", slot, +// prefix}` header/queryParam shape as openapi (so the apiKey template reuses +// `migrateOpenApiAuthTemplate`), plus an `auth:{kind:"none"|"oauth2", +// connectionSlot?}` block. The oauth method has no securitySchemeName (unlike +// openapi), so its template slug is the conventional `oauth2`; its real +// endpoints/scopes live on the connection's `provider_state`, not the config. +// --------------------------------------------------------------------------- + +/** The conventional oauth method slug for mcp/graphql (which carry no + * securitySchemeName). A connection's `template` points at it. */ +export const OAUTH_TEMPLATE_SLUG = "oauth2"; + +export interface V1McpSourceConfig { + readonly endpoint?: string; + readonly transport?: string; + readonly remoteTransport?: string; + readonly headers?: Record; + readonly queryParams?: Record; + readonly auth?: V1SourceAuth; +} + +export interface V1GraphqlSourceConfig { + readonly endpoint?: string; + readonly name?: string; + readonly headers?: Record; + readonly queryParams?: Record; + readonly auth?: V1SourceAuth; +} + +/** Fold a v1 `auth:{kind:oauth2, connectionSlot}` into the slot maps so the + * connection that binds that slot resolves to the conventional oauth method. */ +const withSourceAuthOauth = ( + auth: V1SourceAuth | undefined, + slotToTemplateSlug: Record, + slotToVariable: Record, +): void => { + if (auth?.kind === "oauth2" && auth.connectionSlot) { + slotToTemplateSlug[auth.connectionSlot] = OAUTH_TEMPLATE_SLUG; + slotToVariable[auth.connectionSlot] = PRIMARY_INPUT_VARIABLE; + } +}; + +export const migrateMcpSourceConfig = (v1: V1McpSourceConfig): MigratedSourceConfig => { + const apikey = migrateOpenApiAuthTemplate({ headers: v1.headers, queryParams: v1.queryParams }); + const slotToTemplateSlug = { ...apikey.slotToTemplateSlug }; + const slotToVariable = { ...apikey.slotToVariable }; + withSourceAuthOauth(v1.auth, slotToTemplateSlug, slotToVariable); + const config = { + ...(v1.endpoint !== undefined ? { endpoint: v1.endpoint } : {}), + ...(v1.transport !== undefined ? { transport: v1.transport } : {}), + ...(v1.remoteTransport !== undefined ? { remoteTransport: v1.remoteTransport } : {}), + ...(Object.keys(apikey.staticHeaders).length > 0 ? { headers: apikey.staticHeaders } : {}), + ...(Object.keys(apikey.staticQueryParams).length > 0 + ? { queryParams: apikey.staticQueryParams } + : {}), + auth: migrateSourceAuth(v1.auth), + ...(apikey.authenticationTemplate.length > 0 + ? { authenticationTemplate: apikey.authenticationTemplate } + : {}), + }; + return { config, slotToTemplateSlug, slotToVariable, warnings: apikey.warnings }; +}; + +export const migrateGraphqlSourceConfig = (v1: V1GraphqlSourceConfig): MigratedSourceConfig => { + const apikey = migrateOpenApiAuthTemplate({ headers: v1.headers, queryParams: v1.queryParams }); + const slotToTemplateSlug = { ...apikey.slotToTemplateSlug }; + const slotToVariable = { ...apikey.slotToVariable }; + withSourceAuthOauth(v1.auth, slotToTemplateSlug, slotToVariable); + const config = { + ...(v1.endpoint !== undefined ? { endpoint: v1.endpoint } : {}), + ...(v1.name !== undefined ? { name: v1.name } : {}), + ...(Object.keys(apikey.staticHeaders).length > 0 ? { headers: apikey.staticHeaders } : {}), + ...(Object.keys(apikey.staticQueryParams).length > 0 + ? { queryParams: apikey.staticQueryParams } + : {}), + auth: migrateSourceAuth(v1.auth), + ...(apikey.authenticationTemplate.length > 0 + ? { authenticationTemplate: apikey.authenticationTemplate } + : {}), + }; + return { config, slotToTemplateSlug, slotToVariable, warnings: apikey.warnings }; +}; + +// =========================================================================== +// planMigration — the WEAVE. +// +// Composes the pure transforms above into a complete, side-effect-free plan the +// cloud/local runners execute. Built against the prod-verified model: exactly ONE +// connection per (scope, source); a `credential_binding` ties them (kind=connection +// → an oauth connection; kind=secret → a synthesized apiKey connection, 1–2 slots; +// kind=text → inline); no (scope, source) is both oauth AND apiKey. Secret VALUES +// are NOT resolved here — each `SecretOp` carries the v1 read descriptor + the +// deterministic v2 item id, and the runner does the vault read/write. +// =========================================================================== + +export interface V1SourceRow { + readonly scopeId: string; + readonly id: string; + readonly pluginId: string; + readonly name: string; +} + +export interface V1ProviderState { + readonly kind?: string; + readonly clientId?: string; + readonly clientIdSecretId?: string; + readonly clientIdSecretScopeId?: string | null; + readonly clientSecretSecretId?: string; + readonly clientSecretSecretScopeId?: string | null; + readonly tokenEndpoint?: string; + readonly authorizationEndpoint?: string; + readonly authorizationServerUrl?: string; + readonly authorizationServerMetadataUrl?: string; + readonly authorizationServerMetadata?: { + readonly authorization_endpoint?: string; + } | null; + readonly issuerUrl?: string; + readonly resource?: string | null; + readonly scopes?: readonly string[]; + readonly scope?: string; +} + +export interface V1ConnectionRow { + readonly id: string; + readonly scopeId: string; + readonly provider: string; + readonly identityLabel: string | null; + readonly accessTokenSecretId: string | null; + readonly refreshTokenSecretId: string | null; + readonly expiresAt: number | null; + readonly providerState: V1ProviderState | null; +} + +export interface V1BindingRow { + readonly scopeId: string; + readonly sourceScopeId?: string; + readonly sourceId: string; + readonly slotKey: string; + readonly kind: "secret" | "connection" | "text"; + readonly secretId: string | null; + readonly secretScopeId?: string | null; + readonly connectionId: string | null; + readonly textValue: string | null; +} + +export interface V1PolicyRow { + readonly id?: string; + readonly scopeId: string; + readonly pattern: string; + readonly action: string; + readonly position?: string; +} + +export interface V1SecretRow { + readonly id: string; + readonly scopeId: string; + readonly name: string; + readonly provider: string; + readonly ownedByConnectionId: string | null; +} + +/** A v1 secret value to (re-)materialize in the v2 store. The runner resolves + * `fromSecret` via the provider (walking the scope-stack for client-* roles) or + * writes `fromText` verbatim, to the deterministic `itemId` under `owner`. */ +export interface SecretOp { + readonly itemId: string; + readonly role: SecretRole; + readonly owner: OwnerKeys; + readonly targetProvider: string; + readonly fromSecret?: { + readonly scopeId: string; + readonly secretId: string; + readonly provider: string; + }; + readonly fromText?: string; +} + +export interface PlannedConnectionFull { + readonly credentialScopeId: string; + readonly sourceScopeId: string; + readonly sourceId: string; + readonly row: PlannedConnectionRow; + /** variable → v2 item id (the connection's `item_ids` map). */ + readonly itemIds: Record; + readonly refreshItemId: string | null; +} + +export interface PlannedOAuthClientFull extends DedupedOAuthClient { + /** Vault item id for the client secret (when one existed); else null. */ + readonly clientSecretItemId: string | null; +} + +export interface PlannedPolicy { + readonly owner: OwnerKeys; + readonly id: string; + readonly pattern: string; + readonly action: string; + readonly position: string; + readonly status: "ok" | "static" | "dead-inert"; +} + +export interface MigrationReport { + readonly integrations: number; + readonly connections: number; + readonly oauthClients: number; + readonly secretOps: number; + /** v1 connection rows with no source binding — not migrated (stale residue). */ + readonly staleConnections: number; + readonly policies: { readonly ok: number; readonly static: number; readonly deadInert: number }; + readonly warnings: readonly string[]; +} + +export interface MigrationPlan { + readonly integrations: readonly PlannedIntegrationRow[]; + readonly oauthClients: readonly PlannedOAuthClientFull[]; + readonly connections: readonly PlannedConnectionFull[]; + readonly secretOps: readonly SecretOp[]; + readonly policies: readonly PlannedPolicy[]; + readonly report: MigrationReport; +} + +export interface MigrationInput { + readonly sources: readonly V1SourceRow[]; + /** `${scopeId} ${sourceId}` → the migrated config + slot maps (the runner + * builds these per kind via the `migrate*SourceConfig` assemblers). */ + readonly migratedConfigs: ReadonlyMap; + /** `${sourceScopeId} ${sourceId}` → live OAuth resource discovered from + * protected-resource metadata. Runners populate this for MCP sources; the + * pure planner stays deterministic and only consumes explicit overrides. */ + readonly oauthResourceOverrides?: ReadonlyMap; + readonly connections: readonly V1ConnectionRow[]; + readonly bindings: readonly V1BindingRow[]; + readonly secrets: readonly V1SecretRow[]; + readonly policies: readonly V1PolicyRow[]; + /** Tool `source_id`s with no `source` row (Class B orphans) — folded into the + * slug map so their policies/tools survive. */ + readonly toolSourceIds: readonly string[]; + readonly nowMs: number; + readonly ownerForScope?: ScopeOwnerResolver; + readonly defaultWritableProvider?: string; +} + +/** The map key joining a (scopeId, sourceId) pair. Exported so the runner keys + * `migratedConfigs` identically to the weave's lookup. */ +export const migrationSourceKey = (scopeId: string, sourceId: string): string => + `${scopeId} ${sourceId}`; + +const sourceKey = migrationSourceKey; + +const bindingSourceScope = (binding: V1BindingRow): string => + binding.sourceScopeId ?? binding.scopeId; + +const secretExists = ( + secrets: readonly V1SecretRow[], + scopeId: string, + secretId: string, +): boolean => secrets.some((secret) => secret.scopeId === scopeId && secret.id === secretId); + +const resolveProviderStateSecretScope = ( + secrets: readonly V1SecretRow[], + options: { + readonly explicitScopeId?: string | null; + readonly connectionScopeId: string; + readonly sourceScopeId: string; + readonly secretId: string; + }, +): string => { + if (options.explicitScopeId) return options.explicitScopeId; + if (secretExists(secrets, options.sourceScopeId, options.secretId)) return options.sourceScopeId; + return options.connectionScopeId; +}; + +const bindingSecretScope = (binding: V1BindingRow): string => + binding.secretScopeId ?? binding.scopeId; + +const slugifyName = (name: string, fallback = "account"): string => + String(connectionIdentifier(name, fallback)); + +const secretRefKey = (scopeId: string, secretId: string): string => `${scopeId}\0${secretId}`; + +const staticConnectionNameForSecretBindings = ( + bindings: readonly V1BindingRow[], + secrets: readonly V1SecretRow[], +): string => { + const refs = new Map(); + for (const binding of bindings) { + if (!binding.secretId) continue; + const scopeId = bindingSecretScope(binding); + refs.set(secretRefKey(scopeId, binding.secretId), { scopeId, secretId: binding.secretId }); + } + + if (refs.size !== 1) return "api-key"; + + const [ref] = refs.values(); + if (!ref) return "api-key"; + const secret = secrets.find((s) => s.scopeId === ref.scopeId && s.id === ref.secretId); + const nameSlug = secret?.name.trim() ? slugifyName(secret.name, "") : ""; + return nameSlug || slugifyName(ref.secretId, "api-key"); +}; + +const scopesFromProviderState = (ps: V1ProviderState | null): readonly string[] => { + if (!ps) return []; + if (ps.scopes && ps.scopes.length > 0) return ps.scopes; + if (ps.scope) return ps.scope.split(/\s+/).filter((s) => s.length > 0); + return []; +}; + +const nonEmptyString = (value: string | null | undefined): string | null => { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +}; + +/** True when the URL has no meaningful path — a bare origin like + * `https://login.microsoftonline.com`. A bare origin is never a usable + * authorize endpoint: redirecting there signs the user in and strands them + * (observed in prod with migrated Microsoft clients). */ +const isBareOrigin = (url: string): boolean => /^https?:\/\/[^/]+\/?$/.test(url); + +const authorizationUrlFromProviderState = ( + ps: V1ProviderState | null, + grant: MigrationGrant, +): string => { + const explicit = + nonEmptyString(ps?.authorizationEndpoint) ?? + nonEmptyString(ps?.authorizationServerMetadata?.authorization_endpoint); + if (explicit) return explicit; + const fallback = + nonEmptyString(ps?.authorizationServerUrl) ?? nonEmptyString(ps?.issuerUrl) ?? ""; + // Client-credentials clients have no browser leg — an empty authorization + // URL is their correct shape; never derive one. + if (grant === "client_credentials") return fallback; + // v1 stored only an issuer/server origin for some providers and discovered + // the authorize endpoint at runtime. v2 stores the endpoint itself, so a + // bare origin would mint a broken client. When the token endpoint is a + // same-origin `…/token` URL, its `…/authorize` sibling is the convention + // (and exactly right for the Microsoft v2.0 endpoints that hit this path); + // prefer that over a guaranteed-dead bare origin. + const token = nonEmptyString(ps?.tokenEndpoint); + if ( + (fallback === "" || isBareOrigin(fallback)) && + token?.endsWith("/token") && + (fallback === "" || token.startsWith(fallback.replace(/\/$/, "") + "/")) + ) { + return token.replace(/\/token$/, "/authorize"); + } + return fallback; +}; + +const secretOpDedupeKey = (op: SecretOp): string => + `${op.targetProvider}\0${op.owner.tenant}\0${op.owner.owner}\0${op.owner.subject}\0${op.itemId}`; + +export const planMigration = (input: MigrationInput): MigrationPlan => { + const warnings: string[] = []; + const secretOps: SecretOp[] = []; + const connections: PlannedConnectionFull[] = []; + const ownerForScope = input.ownerForScope ?? parseScope; + const defaultWritableProvider = input.defaultWritableProvider ?? "workos-vault"; + + // --- Integrations (one per source) + the policy slug map (source ∪ tool ids). + const integrations: PlannedIntegrationRow[] = []; + const slugMap = new Map(); + const sourceIdsByTenant = new Map>(); + const addSourceIdForOwner = (owner: OwnerKeys | null, sourceId: string): void => { + if (!owner) return; + const set = sourceIdsByTenant.get(owner.tenant) ?? new Set(); + set.add(sourceId); + sourceIdsByTenant.set(owner.tenant, set); + }; + for (const id of input.toolSourceIds) slugMap.set(id, id); + for (const source of input.sources) { + slugMap.set(source.id, source.id); + addSourceIdForOwner(ownerForScope(source.scopeId), source.id); + const migrated = input.migratedConfigs.get(sourceKey(source.scopeId, source.id)); + const row = planIntegrationRow({ + scopeId: source.scopeId, + sourceId: source.id, + pluginId: source.pluginId, + description: source.name, + config: migrated?.config ?? {}, + ownerForScope, + }); + if (!row) { + warnings.push(`Skipped source "${source.id}": unparseable scope "${source.scopeId}".`); + continue; + } + integrations.push(row); + for (const w of migrated?.warnings ?? []) warnings.push(`[${source.id}] ${w}`); + } + + const policySlugMapForOwner = (owner: OwnerKeys): ReadonlyMap => { + const tenantSourceIds = sourceIdsByTenant.get(owner.tenant); + if ( + tenantSourceIds?.has(MICROSOFT_GRAPH_CURATED_SLUG) && + !tenantSourceIds.has(MICROSOFT_GRAPH_LEGACY_SLUG) + ) { + return new Map(slugMap).set(MICROSOFT_GRAPH_LEGACY_SLUG, MICROSOFT_GRAPH_CURATED_SLUG); + } + return slugMap; + }; + + // --- Group bindings by (scope, source); each group → one connection. + const groups = new Map(); + for (const b of input.bindings) { + const key = sourceKey(b.scopeId, b.sourceId); + const list = groups.get(key) ?? []; + list.push(b); + groups.set(key, list); + } + const connectionById = new Map(); + for (const c of input.connections) connectionById.set(`${c.scopeId} ${c.id}`, c); + + // Track which secrets a connection/oauth-client consumes, so the leftovers are + // the orphans (migrate-all). Keyed `${scopeId} ${secretId}`. + const consumed = new Set(); + const consume = (scopeId: string, secretId: string): void => { + consumed.add(`${scopeId} ${secretId}`); + }; + // Connection rows actually bound to a source (and thus migrated). v1 leaves + // stale, unbound connection rows behind (disconnect/re-auth residue); they have + // no source to attach to in v2, so they're not migrated — their tokens fall to + // the orphan re-key (migrate-all). Tracked for transparency in the report. + const boundConnections = new Set(); + + // OAuth client inputs collected for dedup; each connection records its dedup + // key so it can pick up the assigned slug after dedup runs. + const oauthClientInputs: PlannedOAuthClientInput[] = []; + const clientSecretItemIdByKey = new Map(); + // Defer the slug wire-up: store (connection plan, dedupKey) pairs. + const pendingClientSlug: { readonly index: number; readonly key: string }[] = []; + + for (const [key, bindings] of groups) { + const [scopeId, sourceId] = key.split(" "); + if (!scopeId || !sourceId) continue; + const sourceScopeId = bindings[0] ? bindingSourceScope(bindings[0]) : scopeId; + const owner = ownerForScope(scopeId); + if (!owner) { + warnings.push(`Skipped binding group "${sourceId}": unparseable scope "${scopeId}".`); + continue; + } + const config = input.migratedConfigs.get(sourceKey(sourceScopeId, sourceId)); + const slotTemplate = (slot: string): string => + config?.slotToTemplateSlug[slot] ?? API_KEY_TEMPLATE_SLUG; + const slotVar = (slot: string): string => + config?.slotToVariable[slot] ?? PRIMARY_INPUT_VARIABLE; + + const connBinding = bindings.find((b) => b.kind === "connection"); + const secretBindings = bindings.filter( + (b) => b.kind === "secret" && !isOAuthClientCredentialSlot(b.slotKey), + ); + const textBindings = bindings.filter((b) => b.kind === "text"); + + if (connBinding && connBinding.connectionId) { + // OAuth connection. + const conn = connectionById.get(`${scopeId} ${connBinding.connectionId}`); + if (!conn) { + warnings.push( + `Connection "${connBinding.connectionId}" referenced by a binding is missing.`, + ); + continue; + } + boundConnections.add(`${conn.scopeId} ${conn.id}`); + const ps = conn.providerState; + const grant = migrateGrant((ps?.kind as V1ConnectionKind) ?? "authorization-code"); + const oauthTargetProvider = + conn.provider === "oauth2" ? defaultWritableProvider : conn.provider; + const itemIds: Record = {}; + let refreshItemId: string | null = null; + if (conn.accessTokenSecretId) { + const itemId = migratedItemId(scopeId, conn.accessTokenSecretId); + const provider = providerForSecret(input.secrets, scopeId, conn.accessTokenSecretId); + secretOps.push({ + itemId, + role: "oauth-access", + owner, + targetProvider: oauthTargetProvider, + fromSecret: { scopeId, secretId: conn.accessTokenSecretId, provider }, + }); + itemIds[PRIMARY_INPUT_VARIABLE] = itemId; + consume(scopeId, conn.accessTokenSecretId); + } + if (conn.refreshTokenSecretId) { + refreshItemId = migratedItemId(scopeId, conn.refreshTokenSecretId); + const provider = providerForSecret(input.secrets, scopeId, conn.refreshTokenSecretId); + secretOps.push({ + itemId: refreshItemId, + role: "oauth-refresh", + owner, + targetProvider: oauthTargetProvider, + fromSecret: { scopeId, secretId: conn.refreshTokenSecretId, provider }, + }); + consume(scopeId, conn.refreshTokenSecretId); + } + // OAuth client. + let clientSecretItemId: string | null = null; + if (ps?.clientSecretSecretId) { + const clientSecretScopeId = resolveProviderStateSecretScope(input.secrets, { + explicitScopeId: ps.clientSecretSecretScopeId, + connectionScopeId: scopeId, + sourceScopeId, + secretId: ps.clientSecretSecretId, + }); + clientSecretItemId = migratedItemId(clientSecretScopeId, ps.clientSecretSecretId); + const provider = providerForSecret( + input.secrets, + clientSecretScopeId, + ps.clientSecretSecretId, + ); + secretOps.push({ + itemId: clientSecretItemId, + role: "client-secret", + owner, + targetProvider: oauthTargetProvider, + fromSecret: { scopeId: clientSecretScopeId, secretId: ps.clientSecretSecretId, provider }, + }); + consume(clientSecretScopeId, ps.clientSecretSecretId); + } + const clientIdSecretRef = + ps?.clientIdSecretId != null + ? (() => { + const clientIdScopeId = resolveProviderStateSecretScope(input.secrets, { + explicitScopeId: ps.clientIdSecretScopeId, + connectionScopeId: scopeId, + sourceScopeId, + secretId: ps.clientIdSecretId, + }); + return { + scopeId: clientIdScopeId, + secretId: ps.clientIdSecretId, + provider: providerForSecret(input.secrets, clientIdScopeId, ps.clientIdSecretId), + }; + })() + : null; + if (clientIdSecretRef) consume(clientIdSecretRef.scopeId, clientIdSecretRef.secretId); + if (!ps?.clientId && !clientIdSecretRef) { + warnings.push( + `OAuth connection "${conn.id}" has no client id or client-id secret reference; migrated oauth_client will need repair.`, + ); + } + const clientInput: PlannedOAuthClientInput = { + ownerKeys: owner, + clientId: ps?.clientId ?? "", + clientIdSecretRef, + tokenUrl: ps?.tokenEndpoint ?? "", + authorizationUrl: authorizationUrlFromProviderState(ps, grant), + authorizationServerMetadataUrl: nonEmptyString(ps?.authorizationServerMetadataUrl), + grant, + resource: + input.oauthResourceOverrides?.get(sourceKey(sourceScopeId, sourceId)) ?? + ps?.resource ?? + null, + clientSecretRef: ps?.clientSecretSecretId ?? null, + }; + oauthClientInputs.push(clientInput); + const dedupKey = oauthClientSlugKey({ + ownerKeys: owner, + clientId: clientInput.clientId, + clientIdSecretRef: clientInput.clientIdSecretRef, + tokenUrl: clientInput.tokenUrl, + }); + clientSecretItemIdByKey.set(dedupKey, clientSecretItemId); + + const row = planConnectionRow({ + scopeId, + integration: sourceId, + name: slugifyName(conn.identityLabel ?? "account"), + template: slotTemplate(connBinding.slotKey), + provider: oauthTargetProvider, + identityLabel: conn.identityLabel, + grant, + v1ExpiresAt: conn.expiresAt, + oauthScopes: scopesFromProviderState(ps), + oauthClientSlug: null, // wired after dedup + oauthClientOwner: owner, + nowMs: input.nowMs, + ownerForScope, + }); + if (!row) continue; + const index = + connections.push({ + credentialScopeId: scopeId, + sourceScopeId, + sourceId, + row, + itemIds, + refreshItemId, + }) - 1; + pendingClientSlug.push({ index, key: dedupKey }); + } else if (secretBindings.length > 0) { + // apiKey connection (one or more distinct inputs into one connection). A + // single-secret v1 binding keeps the user's secret label as the v2 account + // name; multi-input methods keep the deterministic generic fallback. + const itemIds: Record = {}; + let template = API_KEY_TEMPLATE_SLUG; + const providers = new Set(); + let targetProvider: string | null = null; + const connectionName = staticConnectionNameForSecretBindings(secretBindings, input.secrets); + for (const b of secretBindings) { + if (!b.secretId) continue; + const secretScopeId = bindingSecretScope(b); + const variable = slotVar(b.slotKey); + const itemId = migratedItemId(secretScopeId, b.secretId); + const provider = providerForSecret(input.secrets, secretScopeId, b.secretId); + targetProvider ??= provider; + providers.add(provider); + secretOps.push({ + itemId, + role: "apikey", + owner, + targetProvider, + fromSecret: { scopeId: secretScopeId, secretId: b.secretId, provider }, + }); + itemIds[variable] = itemId; + template = slotTemplate(b.slotKey); + consume(secretScopeId, b.secretId); + } + // All-null secret ids (malformed v1 rows) would plan a credentialed + // connection with an empty `item_ids` map — a credential with no + // credential that the runtime refuses to produce tools for. Skip loudly. + if (Object.keys(itemIds).length === 0) { + warnings.push( + `Skipped binding group "${sourceId}" in scope "${scopeId}": its secret bindings reference no secrets.`, + ); + continue; + } + const provider = targetProvider ?? defaultWritableProvider; + if (providers.size > 1) { + warnings.push( + `Connection "${sourceId}" in scope "${scopeId}" uses multiple secret providers; v2 supports one provider per connection and will use "${provider}".`, + ); + } + const row = planConnectionRow({ + scopeId, + integration: sourceId, + name: connectionName, + template, + provider, + identityLabel: null, + grant: "authorization_code", + v1ExpiresAt: null, + oauthScopes: [], + oauthClientSlug: null, + oauthClientOwner: null, + nowMs: input.nowMs, + ownerForScope, + }); + if (row) { + connections.push({ + credentialScopeId: scopeId, + sourceScopeId, + sourceId, + row, + itemIds, + refreshItemId: null, + }); + } + } else if (textBindings.length > 0) { + // Inline text connection (rare — 2 in prod). + const itemIds: Record = {}; + let template = API_KEY_TEMPLATE_SLUG; + for (const b of textBindings) { + const variable = slotVar(b.slotKey); + // The source id is part of the key: two sources in one scope can bind + // the same slot (e.g. `header:authorization`) with different values — + // without it they'd collide on one item id and one would silently + // read the other's secret. + const itemId = migratedItemId(scopeId, `text:${sourceId}:${b.slotKey}`); + secretOps.push({ + itemId, + role: "apikey", + owner, + targetProvider: defaultWritableProvider, + fromText: b.textValue ?? "", + }); + itemIds[variable] = itemId; + template = slotTemplate(b.slotKey); + } + const row = planConnectionRow({ + scopeId, + integration: sourceId, + name: "inline", + template, + provider: defaultWritableProvider, + identityLabel: null, + grant: "authorization_code", + v1ExpiresAt: null, + oauthScopes: [], + oauthClientSlug: null, + oauthClientOwner: null, + nowMs: input.nowMs, + ownerForScope, + }); + if (row) { + connections.push({ + credentialScopeId: scopeId, + sourceScopeId, + sourceId, + row, + itemIds, + refreshItemId: null, + }); + } + } else { + // Nothing migratable in the group: a connection binding without a + // connection id, or only OAuth client-credential slots (the app config + // migrates via the bound connection's provider state — an app that was + // configured but never connected has nothing to attach to). Say so + // instead of dropping the group silently. + warnings.push( + `Skipped binding group "${sourceId}" in scope "${scopeId}": no migratable credential binding (its secrets fall to the orphan re-key).`, + ); + } + } + + // --- No-auth sources (mcp/graphql `auth.kind === "none"`) have no credential + // bindings, but v2 produces tools per CONNECTION — without one the migrated + // integration is dead (no tools, nothing to invoke). Plan the canonical + // no-auth connection: template "none", empty item_ids. (This is the planner + // fix for the gap that required the prod `{}` backfill.) + for (const source of input.sources) { + if (groups.has(sourceKey(source.scopeId, source.id))) continue; + const migrated = input.migratedConfigs.get(sourceKey(source.scopeId, source.id)); + const auth = (migrated?.config as { auth?: { kind?: string } } | undefined)?.auth; + if (auth?.kind !== "none") continue; + const row = planConnectionRow({ + scopeId: source.scopeId, + integration: source.id, + name: "workspace", + template: "none", + provider: defaultWritableProvider, + identityLabel: null, + grant: "authorization_code", + v1ExpiresAt: null, + oauthScopes: [], + oauthClientSlug: null, + oauthClientOwner: null, + nowMs: input.nowMs, + ownerForScope, + }); + if (row) { + connections.push({ + credentialScopeId: source.scopeId, + sourceScopeId: source.scopeId, + sourceId: source.id, + row, + itemIds: {}, + refreshItemId: null, + }); + } + } + + // --- Dedup oauth clients, then wire each connection's slug + secret item id. + const dedup = dedupeOAuthClients(oauthClientInputs); + const oauthClients: PlannedOAuthClientFull[] = dedup.clients.map((c) => { + const dedupKey = oauthClientSlugKey({ + ownerKeys: c.ownerKeys, + clientId: c.clientId, + clientIdSecretRef: c.clientIdSecretRef, + tokenUrl: c.tokenUrl, + }); + return { ...c, clientSecretItemId: clientSecretItemIdByKey.get(dedupKey) ?? null }; + }); + for (const { index, key } of pendingClientSlug) { + const slug = dedup.slugByDedupKey[key] ?? null; + const existing = connections[index]; + if (existing) { + connections[index] = { ...existing, row: { ...existing.row, oauthClientSlug: slug } }; + } + } + + // --- Orphan secrets (migrate-all): everything not consumed above + not an + // OAuth-token secret already handled. Re-keyed standalone under its scope owner. + for (const s of input.secrets) { + if (consumed.has(`${s.scopeId} ${s.id}`)) continue; + const owner = ownerForScope(s.scopeId); + if (!owner) { + warnings.push(`Skipped orphan secret "${s.id}": unparseable scope "${s.scopeId}".`); + continue; + } + secretOps.push({ + itemId: migratedItemId(s.scopeId, s.id), + role: "orphan", + owner, + targetProvider: s.provider, + fromSecret: { scopeId: s.scopeId, secretId: s.id, provider: s.provider }, + }); + } + + // --- Policies: transform; dead-source ones KEEP INERT (decided) — emitted with + // their original pattern, which matches no v2 4-segment address. + const policies: PlannedPolicy[] = []; + let ok = 0; + let staticN = 0; + let deadInert = 0; + for (const p of input.policies) { + const owner = ownerForScope(p.scopeId); + if (!owner) { + warnings.push(`Skipped policy "${p.pattern}": unparseable scope "${p.scopeId}".`); + continue; + } + const result = migratePolicyPattern(p.pattern, policySlugMapForOwner(owner)); + if (result.kind === "dead") { + deadInert += 1; + warnings.push( + `Dead-source policy kept inert: "${p.pattern}" (slug "${result.slug}" removed).`, + ); + policies.push({ + owner, + id: p.id ?? fallbackPolicyId(p.scopeId, p.pattern, p.action), + pattern: p.pattern, + action: p.action, + position: p.position ?? String(policies.length).padStart(6, "0"), + status: "dead-inert", + }); + } else { + if (result.kind === "static") staticN += 1; + else ok += 1; + policies.push({ + owner, + id: p.id ?? fallbackPolicyId(p.scopeId, p.pattern, p.action), + pattern: result.pattern, + action: p.action, + position: p.position ?? String(policies.length).padStart(6, "0"), + status: result.kind, + }); + } + } + + // Stale (unbound) v1 connection rows — counted for transparency. + const staleConnections = input.connections.filter( + (c) => !boundConnections.has(`${c.scopeId} ${c.id}`), + ).length; + if (staleConnections > 0) { + warnings.push( + `${staleConnections} unbound v1 connection row(s) were not migrated (no source binding); their tokens migrate as orphan secrets.`, + ); + } + + // Dedupe secret ops by their provider + owner partition + deterministic item id. + // WorkOS Vault values are globally named by item id, but the metadata sidecar is + // owner-scoped; collapsing only by item id drops metadata for other owners. + const dedupedSecretOps = [...new Map(secretOps.map((o) => [secretOpDedupeKey(o), o])).values()]; + + return { + integrations, + oauthClients, + connections, + secretOps: dedupedSecretOps, + policies, + report: { + integrations: integrations.length, + connections: connections.length, + oauthClients: oauthClients.length, + secretOps: dedupedSecretOps.length, + staleConnections, + policies: { ok, static: staticN, deadInert }, + warnings, + }, + }; +}; + +const providerForSecret = ( + secrets: readonly V1SecretRow[], + scopeId: string, + secretId: string, +): string => + secrets.find((s) => s.scopeId === scopeId && s.id === secretId)?.provider ?? "workos-vault"; diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts new file mode 100644 index 000000000..d327a4d8b --- /dev/null +++ b/packages/core/sdk/src/oauth-client.ts @@ -0,0 +1,222 @@ +import type { Effect } from "effect"; +import { Schema } from "effect"; + +import type { Connection } from "./connection"; +import type { StorageFailure } from "./fuma-runtime"; +import { + type AuthTemplateSlug, + type ConnectionName, + type IntegrationSlug, + OAuthClientSlug, + OAuthState, + type Owner, +} from "./ids"; + +/* The v2 OAuth surface contracts. OAuth is a credential mechanism, not an + * integration type. A client is a registered app; running its flow mints a + * Connection. The client is self-contained (carries its own endpoints) and + * integration-independent, so the same app can back connections on whatever + * integrations share that provider. + * + * The OAuth 2.1 *implementation* (PKCE, DCR, token exchange + refresh) lives in + * `oauth-helpers` / `oauth-discovery` / `oauth-service`; these are the public + * input/output shapes the executor's `oauth.*` namespace speaks. */ + +export type OAuthGrant = "authorization_code" | "client_credentials"; + +/** Provider OAuth config an integration declares as one of its auth templates — + * what to request. (The flow itself runs off the self-contained OAuthClient.) */ +export interface OAuthAuthentication { + readonly slug: AuthTemplateSlug; + readonly type: "oauth"; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly scopes: readonly string[]; +} + +/** A registered OAuth app — pure app identity: clientId/secret + its endpoints. + * Owner-scoped: a shared org app or a user's own BYO app. The app does NOT carry + * scopes — what to request is the INTEGRATION's concern (`OAuthAuthentication. + * scopes`, surfaced via the declared auth method), so the same app can back any + * integration without pinning a scope set. */ +export interface OAuthClient { + readonly owner: Owner; + readonly slug: OAuthClientSlug; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly grant: OAuthGrant; + readonly clientId: string; + /** The literal client secret. Stored out-of-band in the credential provider + * (vault item id), never inline. Empty string for public / PKCE clients. */ + readonly clientSecret: string; + /** RFC 8707 Resource Indicator (MCP). Carried so the refresh request can keep + * the re-minted token bound to the same resource. Null/omitted otherwise. */ + readonly resource?: string | null; +} + +export type OAuthClientOrigin = + | { readonly kind: "manual" } + | { + readonly kind: "dynamic_client_registration"; + readonly integration?: IntegrationSlug | null; + }; + +export type CreateOAuthClientInput = OAuthClient & { + readonly origin?: OAuthClientOrigin; +}; + +/** Metadata-only projection of a registered client for listing in the UI. + * Deliberately omits `clientSecret` — the secret is never returned over the + * read surface. `clientId` is included (it is not a secret; it is sent in the + * authorize URL the user's browser visits). */ +export interface OAuthClientSummary { + readonly owner: Owner; + readonly slug: OAuthClientSlug; + readonly grant: OAuthGrant; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly resource?: string | null; + readonly clientId: string; + readonly origin: OAuthClientOrigin; +} + +/** Flow-aware result of `oauth.start` — the status says what's next. */ +export type ConnectResult = + | { readonly status: "connected"; readonly connection: Connection } + | { + readonly status: "redirect"; + readonly authorizationUrl: string; + readonly state: OAuthState; + }; + +/** Start a flow through a client to mint a connection for one integration. + * `template` is the integration's oauth template the minted token is applied + * through. */ +export interface OAuthStartInput { + readonly client: OAuthClientSlug; + /** The owner that owns `client`. Supplied explicitly (the picker knows it), so + * a Personal connection can be minted through a shared Workspace app without + * any owner-derivation rule. A Workspace connection must use a Workspace app. */ + readonly clientOwner: Owner; + /** The owner the minted CONNECTION is saved under (may differ from `clientOwner`). */ + readonly owner: Owner; + readonly name: ConnectionName; + readonly integration: IntegrationSlug; + readonly template: AuthTemplateSlug; + readonly identityLabel?: string | null; + /** Browser-facing callback URL for this flow. Defaults to the executor's configured redirectUri. */ + readonly redirectUri?: string | null; +} + +export interface OAuthCompleteInput { + readonly state: OAuthState; + readonly code: string; +} + +/** Probe a base/issuer URL for OAuth 2.1 authorization-server metadata so the + * onboarding UI can pre-fill a client's endpoints. */ +export interface OAuthProbeInput { + readonly url: string; +} + +export interface OAuthProbeResult { + readonly authorizationUrl: string; + readonly tokenUrl: string; + /** RFC 8707 resource indicator discovered from protected-resource metadata. + * Persist this on DCR clients so authorize/token/refresh requests stay bound + * to the protected resource. */ + readonly resource?: string | null; + readonly scopesSupported?: readonly string[]; + /** Whether the server advertises dynamic client registration (RFC 7591). */ + readonly registrationEndpoint?: string | null; + /** RFC 8414 `token_endpoint_auth_methods_supported`. Surfaced so DCR can pick + * a public ("none") client when the server allows it. */ + readonly tokenEndpointAuthMethodsSupported?: readonly string[]; +} + +/** Mint an OAuth client via RFC 7591 Dynamic Client Registration and persist it. + * The user pastes NO client id/secret — the authorization server mints a + * (public, PKCE) client which is stored as an owner-scoped `oauth_client`. */ +export interface RegisterDynamicClientInput { + readonly owner: Owner; + readonly slug: OAuthClientSlug; + /** RFC 7591 registration endpoint advertised by the authorization server. */ + readonly registrationEndpoint: string; + readonly authorizationUrl: string; + readonly tokenUrl: string; + /** RFC 8707 Resource Indicator (MCP). Persisted on the minted client when known. */ + readonly resource?: string | null; + readonly scopes: readonly string[]; + /** Auth methods the server advertises. When it allows `none` a public + * (PKCE-only, no secret) client is registered; otherwise `client_secret_post`. */ + readonly tokenEndpointAuthMethodsSupported?: readonly string[]; + /** Human label for the registered app (RFC 7591 `client_name`). */ + readonly clientName?: string; + /** Browser-facing callback URL to register. Defaults to the executor's configured redirectUri. */ + readonly redirectUri?: string | null; + /** Integration that requested this dynamic client, when known. */ + readonly originIntegration?: IntegrationSlug | null; +} + +export class OAuthStartError extends Schema.TaggedErrorClass()("OAuthStartError", { + message: Schema.String, +}) {} + +export class OAuthCompleteError extends Schema.TaggedErrorClass()( + "OAuthCompleteError", + { + message: Schema.String, + /** True when the auth-code exchange failed in a way the user must restart. */ + restartRequired: Schema.optional(Schema.Boolean), + }, +) {} + +export class OAuthProbeError extends Schema.TaggedErrorClass()("OAuthProbeError", { + message: Schema.String, +}) {} + +export class OAuthRegisterDynamicError extends Schema.TaggedErrorClass()( + "OAuthRegisterDynamicError", + { message: Schema.String }, +) {} + +export class OAuthSessionNotFoundError extends Schema.TaggedErrorClass()( + "OAuthSessionNotFoundError", + { state: OAuthState }, +) {} + +/** The OAuth surface the executor's `oauth.*` namespace and `ctx.oauth` expose. + * Implemented by `makeOAuthService` (oauth-service.ts), wired by the executor + * with the deps it needs to mint connections. */ +export interface OAuthService { + readonly createClient: ( + input: CreateOAuthClientInput, + ) => Effect.Effect; + /** Mint a client via RFC 7591 Dynamic Client Registration (no pre-shared + * client id/secret) and persist it as an owner-scoped `oauth_client`. */ + readonly registerDynamicClient: ( + input: RegisterDynamicClientInput, + ) => Effect.Effect; + /** All registered clients visible to the caller (their org's shared clients + + * their own user clients), as metadata-only summaries — never the secret. */ + readonly listClients: () => Effect.Effect; + /** Permanently remove a registered OAuth app, keyed by (owner, slug). The + * owner policy on `oauth_client` prevents removing another subject's user app. + * Idempotent: removing an already-gone app succeeds. Connections that + * referenced the slug keep their stored value and fail at the next token + * refresh, prompting a reconnect — this op never cascades into connections. */ + readonly removeClient: ( + owner: Owner, + slug: OAuthClientSlug, + ) => Effect.Effect; + readonly start: ( + input: OAuthStartInput, + ) => Effect.Effect; + readonly complete: ( + input: OAuthCompleteInput, + ) => Effect.Effect; + readonly cancel: (state: OAuthState) => Effect.Effect; + readonly probe: ( + input: OAuthProbeInput, + ) => Effect.Effect; +} diff --git a/packages/core/sdk/src/oauth-discovery.ts b/packages/core/sdk/src/oauth-discovery.ts index 16e18446d..2d7516e3f 100644 --- a/packages/core/sdk/src/oauth-discovery.ts +++ b/packages/core/sdk/src/oauth-discovery.ts @@ -798,6 +798,10 @@ export const beginDynamicAuthorization = ( }); } + // EXPLICIT registration shape (not a fallback of optional input): this DCR + // flow always registers the interactive authorization_code + refresh_token + // grants and the `code` response type. If the AS rejects refresh_token the + // registration request fails loudly rather than silently downgrading. const baseClientMetadata: DynamicClientMetadata = { grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts new file mode 100644 index 000000000..ac262a785 --- /dev/null +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -0,0 +1,453 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + OAuthState, + ToolAddress, + ToolName, +} from "./ids"; +import { OAuthStartError } from "./oauth-client"; +import { definePlugin } from "./plugin"; +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +// Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow +// and OAuth access-token refresh end to end against the test authorization +// server. + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("oauth"); +const CLIENT = OAuthClientSlug.make("acme-app"); + +const oauthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("whoami"), description: "whoami" }], + }), + // Echo the resolved credential value (the OAuth access token) back out. + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Acme", + config: {}, + }), + }), +}))(); + +const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; + +describe("oauth.start / oauth.complete", () => { + it.effect( + "createClient → start (redirect) → complete mints a connection + tools, executable", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.mcpResourceUrl, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main-account"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + // Drive the test AS through the authorization request to obtain the + // callback code + echoed state. + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + expect(callback.state).toBe(String(started.state)); + + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + expect(String(connection.name)).toBe("mainAccount"); + expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); + expect(connection.expiresAt).toBeGreaterThan(Date.now()); + const requests = yield* server.requests; + const authorizationRequest = requests.find( + (r) => r.path === "/authorize" && r.method === "GET", + ); + expect(authorizationRequest?.query.resource).toBe(server.mcpResourceUrl); + const tokenRequest = requests.find( + (r) => r.path === "/token" && r.method === "POST" && r.body.includes("grant_type"), + ); + expect(tokenRequest?.body).toContain( + `resource=${encodeURIComponent(server.mcpResourceUrl)}`, + ); + + // The connection produced its tools. + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.name))).toEqual(["whoami"]); + + // Executing the tool resolves the minted access token, which the AS + // recognises as one it issued. + const out = (yield* executor.execute( + ToolAddress.make("tools.acme.org.mainAccount.whoami"), + {}, + )) as { token: string }; + expect(out.token).toMatch(/^at_/); + expect(yield* server.acceptsAccessToken(out.token)).toBe(true); + }), + ), + ); + + it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + // EXPLICIT: construct the executor WITHOUT a redirectUri (null) — there + // is no silent localhost default. The redirect flow must fail loudly + // rather than handing the provider a wrong `http://127.0.0.1/callback`. + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + redirectUri: null, + }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const error = yield* Effect.flip( + executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }), + ); + // `OAuthStartError` carries a typed `message`; the `Predicate.isTagged` + // guard narrows the union so this read is on a typed failure. + expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); + const startError = error as OAuthStartError; + expect(startError.message).toContain("redirectUri"); + }), + ), + ); + + it.effect("client_credentials start still mints without a redirectUri (no redirect needed)", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + // No redirectUri configured, but client_credentials never redirects — + // it must still mint the connection inline. + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + redirectUri: null, + }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "client_credentials", + clientId: "test-client", + clientSecret: "test-secret", + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("cc"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("connected"); + }), + ), + ); + + it.effect("complete with an unknown state fails OAuthSessionNotFoundError", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer(); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + const result = yield* Effect.flip( + executor.oauth.complete({ + state: OAuthState.make("nonexistent"), + code: "whatever", + }), + ); + expect(Predicate.isTagged("OAuthSessionNotFoundError")(result)).toBe(true); + }), + ), + ); + + it.effect( + "a Workspace (org) app mints a Personal (user) connection — own→shared client resolution", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + // The app is registered under the WORKSPACE (org) — "shared with + // everyone in the workspace". + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + // Start the flow for a PERSONAL (user) connection. The member has no + // own `acme-app`, so the resolver falls back to the shared org app. + const started = yield* executor.oauth.start({ + owner: "user", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + + // Minted under the PERSONAL owner, not the app's org owner — and it + // points back to the shared app it was minted through. + expect(connection.owner).toBe("user"); + expect(String(connection.address)).toBe("tools.acme.user.mine"); + expect(String(connection.oauthClient)).toBe("acme-app"); + // The app's owner is recorded explicitly (Workspace app, Personal connection). + expect(connection.oauthClientOwner).toBe("org"); + }), + ), + ); + + it.effect("a Workspace (org) connection cannot use a member's private (user) app", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + // A PRIVATE app owned by the member. + yield* executor.oauth.createClient({ + owner: "user", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + // Sharing is one-directional (org → members). Backing a Workspace (org) + // connection with a member's private (user) app is rejected by the + // direction guard. + const error = yield* Effect.flip( + executor.oauth.start({ + owner: "org", + clientOwner: "user", + client: CLIENT, + name: ConnectionName.make("shared"), + integration: INTEG, + template: TEMPLATE, + }), + ); + expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); + const startError = error as OAuthStartError; + expect(startError.message).toContain("must use a Workspace app"); + }), + ), + ); +}); + +describe("oauth token refresh in resolveConnectionValue", () => { + it.effect("an expired access token is refreshed before resolving", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const harness = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = harness; + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.mcpResourceUrl, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + + // The first resolve returns the freshly minted access token. + const firstToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + expect(firstToken.token).toMatch(/^at_/); + + // Force the access token to be expired so the next resolve refreshes. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + + const refreshedToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + + // A refresh-token grant minted a brand-new access token. + expect(refreshedToken.token).toMatch(/^at_/); + expect(refreshedToken.token).not.toBe(firstToken.token); + expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true); + const requests = yield* server.requests; + const refreshRequest = requests.find( + (r) => r.path === "/token" && r.method === "POST" && r.body.includes("refresh_token"), + ); + expect(refreshRequest?.body).toContain( + `resource=${encodeURIComponent(server.mcpResourceUrl)}`, + ); + }), + ), + ); + + it.effect( + "refreshes a Personal (user) connection minted through a Workspace (org) app — own→shared client resolution", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const harness = yield* makeTestWorkspaceHarness({ plugins }); + const { executor, config } = harness; + yield* executor.acme.seed(); + + // Workspace (org) app … + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + resource: server.mcpResourceUrl, + }); + + // … minting a PERSONAL (user) connection. + const started = yield* executor.oauth.start({ + owner: "user", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + }); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + const firstToken = (yield* executor.execute( + ToolAddress.make("tools.acme.user.mine.whoami"), + {}, + )) as { token: string }; + expect(firstToken.token).toMatch(/^at_/); + + // Expire it so the next resolve must refresh. The refresh path resolves + // the backing client own→shared(org); WITHOUT that fallback it would + // fail with "OAuth client is no longer registered" since the app is + // org-owned while the connection is user-owned. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "mine"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + + const refreshedToken = (yield* executor.execute( + ToolAddress.make("tools.acme.user.mine.whoami"), + {}, + )) as { token: string }; + expect(refreshedToken.token).toMatch(/^at_/); + expect(refreshedToken.token).not.toBe(firstToken.token); + expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index ac7f02383..e047e88d8 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -14,6 +14,7 @@ import { OAUTH2_REFRESH_SKEW_MS, OAuth2Error, buildAuthorizationUrl, + providerAuthorizeExtras, createPkceCodeChallenge, createPkceCodeVerifier, exchangeAuthorizationCode, @@ -128,6 +129,20 @@ describe("PKCE", () => { // buildAuthorizationUrl // --------------------------------------------------------------------------- +describe("providerAuthorizeExtras (Google offline/consent quirk)", () => { + it("adds access_type=offline + prompt=consent for the Google authorize host", () => { + expect(providerAuthorizeExtras("https://accounts.google.com/o/oauth2/v2/auth")).toEqual({ + access_type: "offline", + prompt: "consent", + }); + }); + it("adds nothing for non-Google hosts or an unparseable URL (token host ≠ authorize host)", () => { + expect(providerAuthorizeExtras("https://accounts.spotify.com/authorize")).toEqual({}); + expect(providerAuthorizeExtras("https://oauth2.googleapis.com/token")).toEqual({}); + expect(providerAuthorizeExtras("not a url")).toEqual({}); + }); +}); + describe("buildAuthorizationUrl", () => { const baseInput = { authorizationUrl: "https://example.com/authorize", @@ -162,20 +177,19 @@ describe("buildAuthorizationUrl", () => { expect(url.searchParams.has("scope")).toBe(false); }); - it("merges Google-style extra params without dropping them", () => { + it("merges provider extra params without dropping them", () => { const url = new URL( buildAuthorizationUrl({ ...baseInput, extraParams: { access_type: "offline", prompt: "consent", - include_granted_scopes: "true", }, }), ); expect(url.searchParams.get("access_type")).toBe("offline"); expect(url.searchParams.get("prompt")).toBe("consent"); - expect(url.searchParams.get("include_granted_scopes")).toBe("true"); + expect(url.searchParams.has("include_granted_scopes")).toBe(false); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 1dead5543..0cb0a088d 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -107,6 +107,10 @@ export const createPkceCodeVerifier = (): string => oauth.generateRandomCodeVeri export const createPkceCodeChallenge = (verifier: string): Promise => oauth.calculatePKCECodeChallenge(verifier); +/** RFC 6749 `state` — an unguessable correlation token minted by `oauth.start` + * and redeemed by `oauth.complete`. */ +export const createOAuthState = (): string => oauth.generateRandomState(); + // --------------------------------------------------------------------------- // Authorization URL builder // --------------------------------------------------------------------------- @@ -140,6 +144,9 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string input.endpointUrlPolicy, ), ); + // Benign default kept by design: a single space is the RFC 6749 scope + // separator. Callers targeting a legacy comma-separated provider pass + // `scopeSeparator` explicitly (see the field's JSDoc). const separator = input.scopeSeparator ?? " "; url.searchParams.set("client_id", input.clientId); url.searchParams.set("redirect_uri", input.redirectUrl); @@ -161,6 +168,31 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string return url.toString(); }; +/** Provider-specific authorize-URL extras that are NOT RFC 6749 params, so the + * generic flow must add them per-provider (keyed off the authorization host). + * + * Google: `access_type=offline` + `prompt=consent` are required to receive (and + * keep receiving, across reconnects / scope changes) a REFRESH TOKEN — without + * them Google issues an access-token-only grant that dies in ~1h and a + * re-consent can silently keep the old scope set. Do not add + * `include_granted_scopes=true` here: with historical grants on the same Google + * consent app, Google folds those unrelated scopes into the new consent flow and + * can fail inside accounts.google.com before returning to our callback. */ +export const providerAuthorizeExtras = ( + authorizationUrl: string, +): Readonly> => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input → no provider extras + try { + const host = new URL(authorizationUrl).host.toLowerCase(); + if (host === "accounts.google.com") { + return { access_type: "offline", prompt: "consent" }; + } + } catch { + // Unparseable authorization URL — let buildAuthorizationUrl surface the error. + } + return {}; +}; + // --------------------------------------------------------------------------- // Error mapping — `oauth4webapi`'s `process*Response` failure shapes are // either a WWW-Authenticate challenge or an RFC 6749 §5.2 error body, @@ -270,6 +302,17 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect