# Conflicts: # apps/cloud/drizzle/meta/0008_snapshot.json # apps/cloud/drizzle/meta/_journal.json # packages/react/src/components/add-account-modal.tsx
Unified · Split
| @@ -1,6 +1,6 @@ | |||
| 1 | { | 1 | { |
| 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", | 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", |
| 3 | "changelog": ["@changesets/changelog-github", { "repo": "RhysSullivan/executor" }], | 3 | "changelog": ["@changesets/changelog-github", { "repo": "UsefulSoftwareCo/executor" }], |
| 4 | "commit": false, | 4 | "commit": false, |
| 5 | "fixed": [ | 5 | "fixed": [ |
| 6 | [ | 6 | [ |
| @@ -7,7 +7,7 @@ body: · 6 unmodified lines | |||
| 7 | attributes: | 7 | attributes: |
| 8 | value: | | 8 | value: | |
| 9 | Before filing, please try the quick fixes in the pinned | 9 | Before filing, please try the quick fixes in the pinned |
| 10 | **[Desktop app crashing? Start here](https://github.com/RhysSullivan/executor/issues?q=is%3Aissue+label%3Acrash-triage)** | 10 | **[Desktop app crashing? Start here](https://github.com/UsefulSoftwareCo/executor/issues?q=is%3Aissue+label%3Acrash-triage)** |
| 11 | guide — most crashes are fixed by updating to the latest version. | 11 | guide — most crashes are fixed by updating to the latest version. |
| 12 | If you're still stuck, the details below help us pin it down fast. | 12 | If you're still stuck, the details below help us pin it down fast. |
| 13 | - type: input | 13 | - type: input |
| @@ -13,7 +13,7 @@ concurrency: · 12 unmodified lines | |||
| 13 | jobs: | 13 | jobs: |
| 14 | format: | 14 | format: |
| 15 | name: Format | 15 | name: Format |
| 16 | runs-on: ubuntu-latest | 16 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 17 | steps: | 17 | steps: |
| 18 | - uses: actions/checkout@v4 | 18 | - uses: actions/checkout@v4 |
| 19 | 19 | ||
| @@ -29,13 +29,20 @@ jobs: · 9 unmodified lines | |||
| 29 | restore-keys: | | 29 | restore-keys: | |
| 30 | ${{ runner.os }}-bun-1.3.11- | 30 | ${{ runner.os }}-bun-1.3.11- |
| 31 | 31 | ||
| 32 | # No prebuilt better-sqlite3 binary matches this runner, so `bun install` | ||
| 33 | # builds it from source via node-gyp, whose undici needs Node 22.10+ | ||
| 34 | # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use. | ||
| 35 | - uses: actions/setup-node@v4 | ||
| 36 | with: | ||
| 37 | node-version: 22 | ||
| 38 | |||
| 32 | - run: bun install --frozen-lockfile | 39 | - run: bun install --frozen-lockfile |
| 33 | 40 | ||
| 34 | - run: bun run format:check | 41 | - run: bun run format:check |
| 35 | 42 | ||
| 36 | lint: | 43 | lint: |
| 37 | name: Lint | 44 | name: Lint |
| 38 | runs-on: ubuntu-latest | 45 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 39 | steps: | 46 | steps: |
| 40 | - uses: actions/checkout@v4 | 47 | - uses: actions/checkout@v4 |
| 41 | 48 | ||
| @@ -51,13 +58,20 @@ jobs: · 9 unmodified lines | |||
| 51 | restore-keys: | | 58 | restore-keys: | |
| 52 | ${{ runner.os }}-bun-1.3.11- | 59 | ${{ runner.os }}-bun-1.3.11- |
| 53 | 60 | ||
| 61 | # No prebuilt better-sqlite3 binary matches this runner, so `bun install` | ||
| 62 | # builds it from source via node-gyp, whose undici needs Node 22.10+ | ||
| 63 | # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use. | ||
| 64 | - uses: actions/setup-node@v4 | ||
| 65 | with: | ||
| 66 | node-version: 22 | ||
| 67 | |||
| 54 | - run: bun install --frozen-lockfile | 68 | - run: bun install --frozen-lockfile |
| 55 | 69 | ||
| 56 | - run: bun run lint | 70 | - run: bun run lint |
| 57 | 71 | ||
| 58 | typecheck: | 72 | typecheck: |
| 59 | name: Typecheck | 73 | name: Typecheck |
| 60 | runs-on: ubuntu-latest | 74 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 61 | steps: | 75 | steps: |
| 62 | - uses: actions/checkout@v4 | 76 | - uses: actions/checkout@v4 |
| 63 | 77 | ||
| @@ -73,13 +87,20 @@ jobs: · 9 unmodified lines | |||
| 73 | restore-keys: | | 87 | restore-keys: | |
| 74 | ${{ runner.os }}-bun-1.3.11- | 88 | ${{ runner.os }}-bun-1.3.11- |
| 75 | 89 | ||
| 90 | # No prebuilt better-sqlite3 binary matches this runner, so `bun install` | ||
| 91 | # builds it from source via node-gyp, whose undici needs Node 22.10+ | ||
| 92 | # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use. | ||
| 93 | - uses: actions/setup-node@v4 | ||
| 94 | with: | ||
| 95 | node-version: 22 | ||
| 96 | |||
| 76 | - run: bun install --frozen-lockfile | 97 | - run: bun install --frozen-lockfile |
| 77 | 98 | ||
| 78 | - run: bun run typecheck | 99 | - run: bun run typecheck |
| 79 | 100 | ||
| 80 | test: | 101 | test: |
| 81 | name: Test | 102 | name: Test |
| 82 | runs-on: ubuntu-latest | 103 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 83 | env: | 104 | env: |
| 84 | TURBO_TEST_CONCURRENCY: 3 | 105 | TURBO_TEST_CONCURRENCY: 3 |
| 85 | steps: | 106 | steps: |
| @@ -130,7 +151,7 @@ jobs: · 44 unmodified lines | |||
| 130 | - { target: cloud, shard: 7/8, shard-name: 7of8 } | 151 | - { target: cloud, shard: 7/8, shard-name: 7of8 } |
| 131 | - { target: cloud, shard: 8/8, shard-name: 8of8 } | 152 | - { target: cloud, shard: 8/8, shard-name: 8of8 } |
| 132 | - target: selfhost | 153 | - target: selfhost |
| 133 | runs-on: ubuntu-latest | 154 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 134 | timeout-minutes: 30 | 155 | timeout-minutes: 30 |
| 135 | steps: | 156 | steps: |
| 136 | - uses: actions/checkout@v4 | 157 | - uses: actions/checkout@v4 |
| @@ -191,7 +212,7 @@ jobs: · 54 unmodified lines | |||
| 191 | # Skipped on pull_request: the local scenario boots a real `executor web` | 212 | # Skipped on pull_request: the local scenario boots a real `executor web` |
| 192 | # plus a browser and is currently flaky on PRs. Still runs on push to main. | 213 | # plus a browser and is currently flaky on PRs. Still runs on push to main. |
| 193 | if: github.event_name != 'pull_request' | 214 | if: github.event_name != 'pull_request' |
| 194 | runs-on: ubuntu-latest | 215 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 195 | timeout-minutes: 20 | 216 | timeout-minutes: 20 |
| 196 | steps: | 217 | steps: |
| 197 | - uses: actions/checkout@v4 | 218 | - uses: actions/checkout@v4 |
| @@ -244,7 +265,7 @@ jobs: · 46 unmodified lines | |||
| 244 | 265 | ||
| 245 | desktop-smoke: | 266 | desktop-smoke: |
| 246 | name: Desktop smoke build | 267 | name: Desktop smoke build |
| 247 | runs-on: ubuntu-latest | 268 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 248 | steps: | 269 | steps: |
| 249 | - uses: actions/checkout@v4 | 270 | - uses: actions/checkout@v4 |
| 250 | 271 | ||
| @@ -260,6 +281,13 @@ jobs: · 9 unmodified lines | |||
| 260 | restore-keys: | | 281 | restore-keys: | |
| 261 | ${{ runner.os }}-bun-1.3.11- | 282 | ${{ runner.os }}-bun-1.3.11- |
| 262 | 283 | ||
| 284 | # No prebuilt better-sqlite3 binary matches this runner, so `bun install` | ||
| 285 | # builds it from source via node-gyp, whose undici needs Node 22.10+ | ||
| 286 | # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use. | ||
| 287 | - uses: actions/setup-node@v4 | ||
| 288 | with: | ||
| 289 | node-version: 22 | ||
| 290 | |||
| 263 | - run: bun install --frozen-lockfile | 291 | - run: bun install --frozen-lockfile |
| 264 | 292 | ||
| 265 | - name: Build web app | 293 | - name: Build web app |
| @@ -277,19 +305,17 @@ jobs: · 11 unmodified lines | |||
| 277 | 305 | ||
| 278 | selfhost-docker-smoke: | 306 | selfhost-docker-smoke: |
| 279 | name: Self-host Docker image | 307 | name: Self-host Docker image |
| 280 | runs-on: ubuntu-latest | 308 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 281 | steps: | 309 | steps: |
| 282 | - uses: actions/checkout@v4 | 310 | - uses: actions/checkout@v4 |
| 283 | 311 | ||
| 284 | - name: Set up Docker Buildx | 312 | - name: Setup Blacksmith Builder |
| 285 | uses: docker/setup-buildx-action@v3 | 313 | uses: useblacksmith/setup-docker-builder@v1 |
| 286 | 314 | ||
| 287 | - name: Build self-host image | 315 | - name: Build self-host image |
| 288 | uses: docker/build-push-action@v6 | 316 | uses: useblacksmith/build-push-action@v2 |
| 289 | with: | 317 | with: |
| 290 | context: . | 318 | context: . |
| 291 | file: apps/host-selfhost/Dockerfile | 319 | file: apps/host-selfhost/Dockerfile |
| 292 | push: false | 320 | push: false |
| 293 | tags: executor-selfhost:ci | 321 | tags: executor-selfhost:ci |
| 294 | cache-from: type=gha | ||
| 295 | cache-to: type=gha,mode=max | ||
| @@ -64,7 +64,7 @@ jobs: · 63 unmodified lines | |||
| 64 | publish: | 64 | publish: |
| 65 | name: Publish | 65 | name: Publish |
| 66 | needs: build-preview-binary | 66 | needs: build-preview-binary |
| 67 | runs-on: ubuntu-latest | 67 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 68 | permissions: | 68 | permissions: |
| 69 | contents: read | 69 | contents: read |
| 70 | pull-requests: write | 70 | pull-requests: write |
| @@ -77,6 +77,13 @@ jobs: · 6 unmodified lines | |||
| 77 | with: | 77 | with: |
| 78 | bun-version: 1.3.11 | 78 | bun-version: 1.3.11 |
| 79 | 79 | ||
| 80 | # No prebuilt better-sqlite3 binary matches this runner, so `bun install` | ||
| 81 | # builds it from source via node-gyp, whose undici needs Node 22.10+ | ||
| 82 | # (webidl.markAsUncloneable). Pin the same runtime the CI jobs use. | ||
| 83 | - uses: actions/setup-node@v4 | ||
| 84 | with: | ||
| 85 | node-version: 22 | ||
| 86 | |||
| 80 | - run: bun install --frozen-lockfile | 87 | - run: bun install --frozen-lockfile |
| 81 | 88 | ||
| 82 | - name: Build executor preview wrapper | 89 | - name: Build executor preview wrapper |
| @@ -19,7 +19,7 @@ permissions: · 18 unmodified lines | |||
| 19 | jobs: | 19 | jobs: |
| 20 | sweep: | 20 | sweep: |
| 21 | name: Destroy previews for closed PRs | 21 | name: Destroy previews for closed PRs |
| 22 | runs-on: ubuntu-latest | 22 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 23 | steps: | 23 | steps: |
| 24 | - uses: actions/checkout@v4 | 24 | - uses: actions/checkout@v4 |
| 25 | 25 | ||
| @@ -27,7 +27,7 @@ jobs: · 26 unmodified lines | |||
| 27 | deploy: | 27 | deploy: |
| 28 | name: Deploy preview | 28 | name: Deploy preview |
| 29 | if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository | 29 | if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository |
| 30 | runs-on: ubuntu-latest | 30 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 31 | steps: | 31 | steps: |
| 32 | - uses: actions/checkout@v4 | 32 | - uses: actions/checkout@v4 |
| 33 | 33 | ||
| @@ -35,6 +35,13 @@ jobs: · 1 unmodified line | |||
| 35 | with: | 35 | with: |
| 36 | bun-version: 1.3.11 | 36 | bun-version: 1.3.11 |
| 37 | 37 | ||
| 38 | # No prebuilt better-sqlite3 binary matches this runner, so `bun install` | ||
| 39 | # builds it from source via node-gyp, whose undici needs Node 22.10+ | ||
| 40 | # (webidl.markAsUncloneable). Pin the same runtime the CI jobs use. | ||
| 41 | - uses: actions/setup-node@v4 | ||
| 42 | with: | ||
| 43 | node-version: 22 | ||
| 44 | |||
| 38 | - run: bun install --frozen-lockfile | 45 | - run: bun install --frozen-lockfile |
| 39 | 46 | ||
| 40 | - name: Deploy | 47 | - name: Deploy |
| @@ -82,7 +89,7 @@ jobs: · 41 unmodified lines | |||
| 82 | teardown: | 89 | teardown: |
| 83 | name: Tear down preview | 90 | name: Tear down preview |
| 84 | if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository | 91 | if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository |
| 85 | runs-on: ubuntu-latest | 92 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 86 | steps: | 93 | steps: |
| 87 | - uses: actions/checkout@v4 | 94 | - uses: actions/checkout@v4 |
| 88 | 95 | ||
| @@ -177,7 +177,7 @@ jobs: · 176 unmodified lines | |||
| 177 | 177 | ||
| 178 | release: | 178 | release: |
| 179 | needs: build | 179 | needs: build |
| 180 | runs-on: ubuntu-latest | 180 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 181 | permissions: | 181 | permissions: |
| 182 | contents: write | 182 | contents: write |
| 183 | 183 | ||
| @@ -21,7 +21,7 @@ concurrency: · 20 unmodified lines | |||
| 21 | 21 | ||
| 22 | jobs: | 22 | jobs: |
| 23 | publish: | 23 | publish: |
| 24 | runs-on: ubuntu-latest | 24 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 25 | permissions: | 25 | permissions: |
| 26 | actions: write | 26 | actions: write |
| 27 | contents: write | 27 | contents: write |
| @@ -18,10 +18,12 @@ concurrency: · 17 unmodified lines | |||
| 18 | 18 | ||
| 19 | jobs: | 19 | jobs: |
| 20 | publish: | 20 | publish: |
| 21 | runs-on: ubuntu-latest | 21 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 22 | permissions: | 22 | permissions: |
| 23 | contents: read | 23 | contents: read |
| 24 | packages: write | 24 | packages: write |
| 25 | env: | ||
| 26 | HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} | ||
| 25 | 27 | ||
| 26 | steps: | 28 | steps: |
| 27 | - name: Checkout | 29 | - name: Checkout |
| @@ -57,6 +59,7 @@ jobs: · 29 unmodified lines | |||
| 57 | version="${RELEASE_TAG#v}" | 59 | version="${RELEASE_TAG#v}" |
| 58 | owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" | 60 | owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" |
| 59 | image="ghcr.io/${owner}/executor-selfhost" | 61 | image="ghcr.io/${owner}/executor-selfhost" |
| 62 | legacy_image="ghcr.io/rhyssullivan/executor-selfhost" | ||
| 60 | revision="$(git rev-parse HEAD)" | 63 | revision="$(git rev-parse HEAD)" |
| 61 | 64 | ||
| 62 | if [[ "$version" == *-* ]]; then | 65 | if [[ "$version" == *-* ]]; then |
| @@ -74,6 +77,11 @@ jobs: · 11 unmodified lines | |||
| 74 | echo "${image}:${version}" | 77 | echo "${image}:${version}" |
| 75 | echo "${image}:${channel}" | 78 | echo "${image}:${channel}" |
| 76 | echo "TAGS" | 79 | echo "TAGS" |
| 80 | echo "legacy_tags<<TAGS" | ||
| 81 | echo "${legacy_image}:${RELEASE_TAG}" | ||
| 82 | echo "${legacy_image}:${version}" | ||
| 83 | echo "${legacy_image}:${channel}" | ||
| 84 | echo "TAGS" | ||
| 77 | echo "labels<<LABELS" | 85 | echo "labels<<LABELS" |
| 78 | echo "org.opencontainers.image.title=Executor self-host" | 86 | echo "org.opencontainers.image.title=Executor self-host" |
| 79 | echo "org.opencontainers.image.description=Single-container self-hosted Executor" | 87 | echo "org.opencontainers.image.description=Single-container self-hosted Executor" |
| @@ -86,8 +94,8 @@ jobs: · 6 unmodified lines | |||
| 86 | - name: Set up QEMU | 94 | - name: Set up QEMU |
| 87 | uses: docker/setup-qemu-action@v3 | 95 | uses: docker/setup-qemu-action@v3 |
| 88 | 96 | ||
| 89 | - name: Set up Docker Buildx | 97 | - name: Setup Blacksmith Builder |
| 90 | uses: docker/setup-buildx-action@v3 | 98 | uses: useblacksmith/setup-docker-builder@v1 |
| 91 | 99 | ||
| 92 | - name: Log in to GHCR | 100 | - name: Log in to GHCR |
| 93 | uses: docker/login-action@v3 | 101 | uses: docker/login-action@v3 |
| @@ -97,7 +105,8 @@ jobs: · 3 unmodified lines | |||
| 97 | password: ${{ secrets.GITHUB_TOKEN }} | 105 | password: ${{ secrets.GITHUB_TOKEN }} |
| 98 | 106 | ||
| 99 | - name: Build and push self-host image | 107 | - name: Build and push self-host image |
| 100 | uses: docker/build-push-action@v6 | 108 | id: build |
| 109 | uses: useblacksmith/build-push-action@v2 | ||
| 101 | with: | 110 | with: |
| 102 | context: . | 111 | context: . |
| 103 | file: apps/host-selfhost/Dockerfile | 112 | file: apps/host-selfhost/Dockerfile |
| @@ -105,3 +114,32 @@ jobs: · 1 unmodified line | |||
| 105 | push: true | 114 | push: true |
| 106 | tags: ${{ steps.image.outputs.tags }} | 115 | tags: ${{ steps.image.outputs.tags }} |
| 107 | labels: ${{ steps.image.outputs.labels }} | 116 | labels: ${{ steps.image.outputs.labels }} |
| 117 | |||
| 118 | - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) | ||
| 119 | if: env.HAS_LEGACY_TOKEN != 'true' | ||
| 120 | run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror." | ||
| 121 | |||
| 122 | - name: Log in to legacy GHCR namespace | ||
| 123 | if: env.HAS_LEGACY_TOKEN == 'true' | ||
| 124 | uses: docker/login-action@v3 | ||
| 125 | with: | ||
| 126 | registry: ghcr.io | ||
| 127 | username: rhyssullivan | ||
| 128 | password: ${{ secrets.GHCR_LEGACY_TOKEN }} | ||
| 129 | |||
| 130 | - name: Mirror self-host image to legacy GHCR namespace | ||
| 131 | if: env.HAS_LEGACY_TOKEN == 'true' | ||
| 132 | shell: bash | ||
| 133 | env: | ||
| 134 | SOURCE_IMAGE: ${{ steps.image.outputs.image }}@${{ steps.build.outputs.digest }} | ||
| 135 | LEGACY_TAGS: ${{ steps.image.outputs.legacy_tags }} | ||
| 136 | run: | | ||
| 137 | set -euo pipefail | ||
| 138 | |||
| 139 | tag_args=() | ||
| 140 | while IFS= read -r tag; do | ||
| 141 | [[ -n "$tag" ]] || continue | ||
| 142 | tag_args+=("-t" "$tag") | ||
| 143 | done <<< "$LEGACY_TAGS" | ||
| 144 | |||
| 145 | docker buildx imagetools create "${tag_args[@]}" "$SOURCE_IMAGE" | ||
| @@ -15,7 +15,7 @@ concurrency: · 14 unmodified lines | |||
| 15 | 15 | ||
| 16 | jobs: | 16 | jobs: |
| 17 | release: | 17 | release: |
| 18 | runs-on: ubuntu-latest | 18 | runs-on: blacksmith-4vcpu-ubuntu-2404 |
| 19 | permissions: | 19 | permissions: |
| 20 | actions: write | 20 | actions: write |
| 21 | contents: write | 21 | contents: write |
| @@ -104,7 +104,7 @@ Identical to beta except skip `release:beta:start`/`stop`. Changesets produce a · 103 unmodified lines | |||
| 104 | - **Never commit until the owner explicitly says so.** Set everything up in the working tree, run `git status`, and stop. | 104 | - **Never commit until the owner explicitly says so.** Set everything up in the working tree, run `git status`, and stop. |
| 105 | - **No AI / Claude / Anthropic / Co-Authored-By trailers** in commits, commit messages, PRs, or any generated file. This is in `CLAUDE.md` — do not violate. | 105 | - **No AI / Claude / Anthropic / Co-Authored-By trailers** in commits, commit messages, PRs, or any generated file. This is in `CLAUDE.md` — do not violate. |
| 106 | - **Branch naming**: `rs/<short-topic>` for Rhys's branches. Beta-start branch: `rs/beta-v<version>-start`. | 106 | - **Branch naming**: `rs/<short-topic>` for Rhys's branches. Beta-start branch: `rs/beta-v<version>-start`. |
| 107 | - **Remote**: `origin` = `https://github.com/RhysSullivan/executor.git`. If another remote appears (e.g. a fork remote), ask whether to remove it. | 107 | - **Remote**: `origin` = `https://github.com/UsefulSoftwareCo/executor.git`. If another remote appears (e.g. a fork remote), ask whether to remove it. |
| 108 | - **Dirty working tree**: if there are uncommitted changes when starting a release, ask whether to include them, stash them, or commit separately first. Don't sweep them into the release commit silently. | 108 | - **Dirty working tree**: if there are uncommitted changes when starting a release, ask whether to include them, stash them, or commit separately first. Don't sweep them into the release commit silently. |
| 109 | - **Don't estimate time** — code is cheap to write. Focus on what to do, not how long it takes. | 109 | - **Don't estimate time** — code is cheap to write. Focus on what to do, not how long it takes. |
| 110 | - **Fact-check scope claims** before publishing. If release notes say "does not affect X", verify by reading the diff. | 110 | - **Fact-check scope claims** before publishing. If release notes say "does not affect X", verify by reading the diff. |
| @@ -225,7 +225,7 @@ The browser e2e scenarios need Playwright's Chromium once per machine: · 224 unmodified lines | |||
| 225 | ## Community | 225 | ## Community |
| 226 | 226 | ||
| 227 | Join the [Discord](https://discord.gg/eF29HBHwM6). To learn more, visit | 227 | Join the [Discord](https://discord.gg/eF29HBHwM6). To learn more, visit |
| 228 | [executor.sh](https://executor.sh) or [Ask DeepWiki](https://deepwiki.com/RhysSullivan/executor). | 228 | [executor.sh](https://executor.sh) or [Ask DeepWiki](https://deepwiki.com/UsefulSoftwareCo/executor). |
| 229 | 229 | ||
| 230 | ## License | 230 | ## License |
| 231 | 231 | ||
| @@ -24,7 +24,7 @@ the self-host Docker image. · 23 unmodified lines | |||
| 24 | - creates or updates the GitHub release with build artifacts | 24 | - creates or updates the GitHub release with build artifacts |
| 25 | - dispatches `.github/workflows/publish-desktop.yml` | 25 | - dispatches `.github/workflows/publish-desktop.yml` |
| 26 | - dispatches `.github/workflows/publish-selfhost-docker.yml` | 26 | - dispatches `.github/workflows/publish-selfhost-docker.yml` |
| 27 | 6. The self-host Docker workflow publishes `ghcr.io/rhyssullivan/executor-selfhost` | 27 | 6. The self-host Docker workflow publishes `ghcr.io/usefulsoftwareco/executor-selfhost` |
| 28 | for `linux/amd64` and `linux/arm64`: | 28 | for `linux/amd64` and `linux/arm64`: |
| 29 | - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest` | 29 | - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest` |
| 30 | - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta` | 30 | - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta` |
| @@ -1,4 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 2 | 5 | ||
| 3 | import { | 6 | import { |
| 4 | cmdSetValue, | 7 | cmdSetValue, |
| @@ -12,6 +15,14 @@ import { · 7 unmodified lines | |||
| 12 | parseSchtasksRunning, | 15 | parseSchtasksRunning, |
| 13 | } from "./service"; | 16 | } from "./service"; |
| 14 | 17 | ||
| 18 | const tempDirs: Array<string> = []; | ||
| 19 | |||
| 20 | afterEach(() => { | ||
| 21 | for (const dir of tempDirs.splice(0)) { | ||
| 22 | rmSync(dir, { recursive: true, force: true }); | ||
| 23 | } | ||
| 24 | }); | ||
| 25 | |||
| 15 | describe("service unit generation", () => { | 26 | describe("service unit generation", () => { |
| 16 | const launchdInput = { | 27 | const launchdInput = { |
| 17 | label: "sh.executor.daemon", | 28 | label: "sh.executor.daemon", |
| @@ -36,16 +47,49 @@ describe("service unit generation", () => { · 18 unmodified lines | |||
| 36 | workingDirectory: "/Users/x/.executor", | 47 | workingDirectory: "/Users/x/.executor", |
| 37 | }; | 48 | }; |
| 38 | 49 | ||
| 50 | const makeExecutorBundle = (bundleIdentifier: string): string => { | ||
| 51 | const root = mkdtempSync(join(tmpdir(), "executor-launchd-bundle-")); | ||
| 52 | tempDirs.push(root); | ||
| 53 | const contentsDir = join(root, "Executor.app", "Contents"); | ||
| 54 | const executableDir = join(contentsDir, "Resources", "executor"); | ||
| 55 | mkdirSync(executableDir, { recursive: true }); | ||
| 56 | writeFileSync( | ||
| 57 | join(contentsDir, "Info.plist"), | ||
| 58 | `<?xml version="1.0" encoding="UTF-8"?> | ||
| 59 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| 60 | <plist version="1.0"> | ||
| 61 | <dict> | ||
| 62 | <key>CFBundleIdentifier</key> | ||
| 63 | <string>${bundleIdentifier}</string> | ||
| 64 | </dict> | ||
| 65 | </plist> | ||
| 66 | `, | ||
| 67 | ); | ||
| 68 | return join(executableDir, "executor"); | ||
| 69 | }; | ||
| 70 | |||
| 71 | const expectLaunchdInvariants = (plist: string, input: typeof launchdInput): void => { | ||
| 72 | const programArguments = input.programArguments | ||
| 73 | .map((arg) => ` <string>${arg}</string>`) | ||
| 74 | .join("\n"); | ||
| 75 | |||
| 76 | expect(plist).toContain("<key>Label</key>"); | ||
| 77 | expect(plist).toContain(`<string>${input.label}</string>`); | ||
| 78 | expect(plist).toContain( | ||
| 79 | ` <key>ProgramArguments</key>\n <array>\n${programArguments}\n </array>`, | ||
| 80 | ); | ||
| 81 | expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/); | ||
| 82 | expect(plist).toMatch( | ||
| 83 | /<key>KeepAlive<\/key>\s*<dict>\s*<key>SuccessfulExit<\/key>\s*<false\/>\s*<\/dict>/, | ||
| 84 | ); | ||
| 85 | }; | ||
| 86 | |||
| 39 | it("renders a launchd plist that restarts on crash but not clean stop", () => { | 87 | it("renders a launchd plist that restarts on crash but not clean stop", () => { |
| 40 | const plist = generateLaunchdPlist(launchdInput); | 88 | const plist = generateLaunchdPlist(launchdInput); |
| 89 | expectLaunchdInvariants(plist, launchdInput); | ||
| 41 | expect(plist).toContain('<plist version="1.0">'); | 90 | expect(plist).toContain('<plist version="1.0">'); |
| 42 | expect(plist).toContain("<key>Label</key>"); | ||
| 43 | expect(plist).toContain("<string>sh.executor.daemon</string>"); | ||
| 44 | expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/); | ||
| 45 | // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout. | 91 | // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout. |
| 46 | expect(plist).toContain("<key>KeepAlive</key>"); | ||
| 47 | expect(plist).toContain("<key>SuccessfulExit</key>"); | 92 | expect(plist).toContain("<key>SuccessfulExit</key>"); |
| 48 | expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/); | ||
| 49 | expect(plist).toContain("<key>ProcessType</key>"); | 93 | expect(plist).toContain("<key>ProcessType</key>"); |
| 50 | expect(plist).toContain("<string>Background</string>"); | 94 | expect(plist).toContain("<string>Background</string>"); |
| 51 | expect(plist).toContain("--foreground"); | 95 | expect(plist).toContain("--foreground"); |
| @@ -59,6 +103,32 @@ describe("service unit generation", () => { · 7 unmodified lines | |||
| 59 | expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD"); | 103 | expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD"); |
| 60 | }); | 104 | }); |
| 61 | 105 | ||
| 106 | it("associates an app-bundled launchd plist with the enclosing bundle identifier", () => { | ||
| 107 | const executablePath = makeExecutorBundle("sh.executor.desktop.test"); | ||
| 108 | const input = { | ||
| 109 | ...launchdInput, | ||
| 110 | programArguments: [executablePath, ...launchdInput.programArguments.slice(1)], | ||
| 111 | }; | ||
| 112 | const plist = generateLaunchdPlist(input); | ||
| 113 | |||
| 114 | expectLaunchdInvariants(plist, input); | ||
| 115 | expect(plist.match(/<key>AssociatedBundleIdentifiers<\/key>/g)).toHaveLength(1); | ||
| 116 | expect(plist).toMatch( | ||
| 117 | /<dict>\s*<key>Label<\/key>\s*<string>sh\.executor\.daemon<\/string>\s*<key>AssociatedBundleIdentifiers<\/key>\s*<array>\s*<string>sh\.executor\.desktop\.test<\/string>\s*<\/array>\s*<key>ProgramArguments<\/key>/, | ||
| 118 | ); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("omits app association for standalone launchd binaries", () => { | ||
| 122 | const input = { | ||
| 123 | ...launchdInput, | ||
| 124 | programArguments: ["/usr/local/bin/executor", ...launchdInput.programArguments.slice(1)], | ||
| 125 | }; | ||
| 126 | const plist = generateLaunchdPlist(input); | ||
| 127 | |||
| 128 | expectLaunchdInvariants(plist, input); | ||
| 129 | expect(plist).not.toContain("AssociatedBundleIdentifiers"); | ||
| 130 | }); | ||
| 131 | |||
| 62 | it("xml-escapes environment values", () => { | 132 | it("xml-escapes environment values", () => { |
| 63 | const plist = generateLaunchdPlist({ | 133 | const plist = generateLaunchdPlist({ |
| 64 | ...launchdInput, | 134 | ...launchdInput, |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { execFile } from "node:child_process"; | 1 | import { execFile } from "node:child_process"; |
| 2 | import { readFileSync } from "node:fs"; | ||
| 2 | import { homedir, userInfo } from "node:os"; | 3 | import { homedir, userInfo } from "node:os"; |
| 3 | import { FileSystem, Path } from "effect"; | 4 | import { FileSystem, Path } from "effect"; |
| 4 | import type { PlatformError } from "effect/PlatformError"; | 5 | import type { PlatformError } from "effect/PlatformError"; |
| @@ -143,6 +144,14 @@ const xmlEscape = (value: string): string => · 138 unmodified lines | |||
| 143 | .replaceAll('"', """) | 144 | .replaceAll('"', """) |
| 144 | .replaceAll("'", "'"); | 145 | .replaceAll("'", "'"); |
| 145 | 146 | ||
| 147 | const xmlUnescape = (value: string): string => | ||
| 148 | value | ||
| 149 | .replaceAll("'", "'") | ||
| 150 | .replaceAll(""", '"') | ||
| 151 | .replaceAll(">", ">") | ||
| 152 | .replaceAll("<", "<") | ||
| 153 | .replaceAll("&", "&"); | ||
| 154 | |||
| 146 | // --------------------------------------------------------------------------- | 155 | // --------------------------------------------------------------------------- |
| 147 | // Shared service environment + program args | 156 | // Shared service environment + program args |
| 148 | // --------------------------------------------------------------------------- | 157 | // --------------------------------------------------------------------------- |
| @@ -209,6 +218,36 @@ const launchdPlistPath = (path: Path.Path): string => · 60 unmodified lines | |||
| 209 | 218 | ||
| 210 | const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs"); | 219 | const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs"); |
| 211 | 220 | ||
| 221 | const DESKTOP_APP_BUNDLE_IDENTIFIER = "sh.executor.desktop"; | ||
| 222 | |||
| 223 | const enclosingAppBundlePath = (executablePath: string): string | null => { | ||
| 224 | const match = executablePath.match(/^(.*\.app)\/Contents\//); | ||
| 225 | return match?.[1] ?? null; | ||
| 226 | }; | ||
| 227 | |||
| 228 | const readBundleIdentifier = (appBundlePath: string): string | null => { | ||
| 229 | try { | ||
| 230 | const plist = readFileSync(`${appBundlePath}/Contents/Info.plist`, "utf8"); | ||
| 231 | const match = plist.match(/<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>([^<]*)<\/string>/); | ||
| 232 | const bundleIdentifier = match ? xmlUnescape(match[1].trim()) : ""; | ||
| 233 | return bundleIdentifier.length > 0 ? bundleIdentifier : null; | ||
| 234 | } catch { | ||
| 235 | return null; | ||
| 236 | } | ||
| 237 | }; | ||
| 238 | |||
| 239 | const associatedBundleIdentifier = (programArguments: ReadonlyArray<string>): string | null => { | ||
| 240 | const executablePath = programArguments[0]; | ||
| 241 | if (!executablePath) return null; | ||
| 242 | const appBundlePath = enclosingAppBundlePath(executablePath); | ||
| 243 | if (!appBundlePath) return null; | ||
| 244 | const bundleIdentifier = readBundleIdentifier(appBundlePath); | ||
| 245 | if (bundleIdentifier) return bundleIdentifier; | ||
| 246 | return /(?:^|\/)Executor\.app\/Contents\//.test(executablePath) | ||
| 247 | ? DESKTOP_APP_BUNDLE_IDENTIFIER | ||
| 248 | : null; | ||
| 249 | }; | ||
| 250 | |||
| 212 | export interface LaunchdPlistOptions { | 251 | export interface LaunchdPlistOptions { |
| 213 | readonly label: string; | 252 | readonly label: string; |
| 214 | readonly programArguments: ReadonlyArray<string>; | 253 | readonly programArguments: ReadonlyArray<string>; |
| @@ -219,7 +258,7 @@ export interface LaunchdPlistOptions { · 4 unmodified lines | |||
| 219 | } | 258 | } |
| 220 | 259 | ||
| 221 | /** | 260 | /** |
| 222 | * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses | 261 | * Render a user LaunchAgent plist. KeepAlive uses |
| 223 | * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero | 262 | * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero |
| 224 | * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM → | 263 | * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM → |
| 225 | * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background | 264 | * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background |
| @@ -229,6 +268,10 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 3 unmodified lines | |||
| 229 | const programArgs = options.programArguments | 268 | const programArgs = options.programArguments |
| 230 | .map((arg) => ` <string>${xmlEscape(arg)}</string>`) | 269 | .map((arg) => ` <string>${xmlEscape(arg)}</string>`) |
| 231 | .join("\n"); | 270 | .join("\n"); |
| 271 | const bundleIdentifier = associatedBundleIdentifier(options.programArguments); | ||
| 272 | const associatedBundleIdentifiers = bundleIdentifier | ||
| 273 | ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${xmlEscape(bundleIdentifier)}</string>\n </array>\n` | ||
| 274 | : ""; | ||
| 232 | const envEntries = Object.entries(options.environment) | 275 | const envEntries = Object.entries(options.environment) |
| 233 | .map( | 276 | .map( |
| 234 | ([key, value]) => | 277 | ([key, value]) => |
| @@ -241,7 +284,7 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 6 unmodified lines | |||
| 241 | <dict> | 284 | <dict> |
| 242 | <key>Label</key> | 285 | <key>Label</key> |
| 243 | <string>${xmlEscape(options.label)}</string> | 286 | <string>${xmlEscape(options.label)}</string> |
| 244 | <key>ProgramArguments</key> | 287 | ${associatedBundleIdentifiers} <key>ProgramArguments</key> |
| 245 | <array> | 288 | <array> |
| 246 | ${programArgs} | 289 | ${programArgs} |
| 247 | </array> | 290 | </array> |
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE "oauth_client" ADD COLUMN "origin_issuer" text; | ||
File renamed without changes.
| @@ -1,5 +1,5 @@ | |||
| 1 | { | 1 | { |
| 2 | "id": "0a228326-fd03-4910-8506-64b57af52736", | 2 | "id": "a0208f9c-622b-4c94-bd09-33bcf37c2462", |
| 3 | "prevId": "2f557ed1-5afc-431b-8623-2a2162793f43", | 3 | "prevId": "2f557ed1-5afc-431b-8623-2a2162793f43", |
| 4 | "version": "7", | 4 | "version": "7", |
| 5 | "dialect": "postgresql", | 5 | "dialect": "postgresql", |
| @@ -245,12 +245,6 @@ · 239 unmodified lines | |||
| 245 | "primaryKey": false, | 245 | "primaryKey": false, |
| 246 | "notNull": false | 246 | "notNull": false |
| 247 | }, | 247 | }, |
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | 248 | "tools_synced_at": { |
| 255 | "name": "tools_synced_at", | 249 | "name": "tools_synced_at", |
| 256 | "type": "bigint", | 250 | "type": "bigint", |
| @@ -531,24 +525,18 @@ · 274 unmodified lines | |||
| 531 | "primaryKey": false, | 525 | "primaryKey": false, |
| 532 | "notNull": false | 526 | "notNull": false |
| 533 | }, | 527 | }, |
| 534 | "config": { | 528 | "config_revised_at": { |
| 535 | "name": "config", | 529 | "name": "config_revised_at", |
| 536 | "type": "json", | 530 | "type": "bigint", |
| 537 | "primaryKey": false, | 531 | "primaryKey": false, |
| 538 | "notNull": false | 532 | "notNull": false |
| 539 | }, | 533 | }, |
| 540 | "health_check": { | 534 | "config": { |
| 541 | "name": "health_check", | 535 | "name": "config", |
| 542 | "type": "json", | 536 | "type": "json", |
| 543 | "primaryKey": false, | 537 | "primaryKey": false, |
| 544 | "notNull": false | 538 | "notNull": false |
| 545 | }, | 539 | }, |
| 546 | "config_revised_at": { | ||
| 547 | "name": "config_revised_at", | ||
| 548 | "type": "bigint", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | 540 | "can_remove": { |
| 553 | "name": "can_remove", | 541 | "name": "can_remove", |
| 554 | "type": "boolean", | 542 | "type": "boolean", |
| @@ -676,6 +664,12 @@ · 121 unmodified lines | |||
| 676 | "primaryKey": false, | 664 | "primaryKey": false, |
| 677 | "notNull": false | 665 | "notNull": false |
| 678 | }, | 666 | }, |
| 667 | "origin_issuer": { | ||
| 668 | "name": "origin_issuer", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 679 | "created_at": { | 673 | "created_at": { |
| 680 | "name": "created_at", | 674 | "name": "created_at", |
| 681 | "type": "timestamp", | 675 | "type": "timestamp", |
| @@ -0,0 +1,1290 @@ | |||
| 1 | { | ||
| 2 | "id": "b07ddbda-be11-4679-9f00-16d1b3b5e879", | ||
| 3 | "prevId": "a0208f9c-622b-4c94-bd09-33bcf37c2462", | ||
| 4 | "version": "7", | ||
| 5 | "dialect": "postgresql", | ||
| 6 | "tables": { | ||
| 7 | "public.accounts": { | ||
| 8 | "name": "accounts", | ||
| 9 | "schema": "", | ||
| 10 | "columns": { | ||
| 11 | "id": { | ||
| 12 | "name": "id", | ||
| 13 | "type": "text", | ||
| 14 | "primaryKey": true, | ||
| 15 | "notNull": true | ||
| 16 | }, | ||
| 17 | "created_at": { | ||
| 18 | "name": "created_at", | ||
| 19 | "type": "timestamp with time zone", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "default": "now()" | ||
| 23 | } | ||
| 24 | }, | ||
| 25 | "indexes": {}, | ||
| 26 | "foreignKeys": {}, | ||
| 27 | "compositePrimaryKeys": {}, | ||
| 28 | "uniqueConstraints": {}, | ||
| 29 | "policies": {}, | ||
| 30 | "checkConstraints": {}, | ||
| 31 | "isRLSEnabled": false | ||
| 32 | }, | ||
| 33 | "public.memberships": { | ||
| 34 | "name": "memberships", | ||
| 35 | "schema": "", | ||
| 36 | "columns": { | ||
| 37 | "account_id": { | ||
| 38 | "name": "account_id", | ||
| 39 | "type": "text", | ||
| 40 | "primaryKey": false, | ||
| 41 | "notNull": true | ||
| 42 | }, | ||
| 43 | "organization_id": { | ||
| 44 | "name": "organization_id", | ||
| 45 | "type": "text", | ||
| 46 | "primaryKey": false, | ||
| 47 | "notNull": true | ||
| 48 | }, | ||
| 49 | "created_at": { | ||
| 50 | "name": "created_at", | ||
| 51 | "type": "timestamp with time zone", | ||
| 52 | "primaryKey": false, | ||
| 53 | "notNull": true, | ||
| 54 | "default": "now()" | ||
| 55 | } | ||
| 56 | }, | ||
| 57 | "indexes": {}, | ||
| 58 | "foreignKeys": { | ||
| 59 | "memberships_account_id_accounts_id_fk": { | ||
| 60 | "name": "memberships_account_id_accounts_id_fk", | ||
| 61 | "tableFrom": "memberships", | ||
| 62 | "tableTo": "accounts", | ||
| 63 | "columnsFrom": ["account_id"], | ||
| 64 | "columnsTo": ["id"], | ||
| 65 | "onDelete": "cascade", | ||
| 66 | "onUpdate": "no action" | ||
| 67 | }, | ||
| 68 | "memberships_organization_id_organizations_id_fk": { | ||
| 69 | "name": "memberships_organization_id_organizations_id_fk", | ||
| 70 | "tableFrom": "memberships", | ||
| 71 | "tableTo": "organizations", | ||
| 72 | "columnsFrom": ["organization_id"], | ||
| 73 | "columnsTo": ["id"], | ||
| 74 | "onDelete": "cascade", | ||
| 75 | "onUpdate": "no action" | ||
| 76 | } | ||
| 77 | }, | ||
| 78 | "compositePrimaryKeys": { | ||
| 79 | "memberships_account_id_organization_id_pk": { | ||
| 80 | "name": "memberships_account_id_organization_id_pk", | ||
| 81 | "columns": ["account_id", "organization_id"] | ||
| 82 | } | ||
| 83 | }, | ||
| 84 | "uniqueConstraints": {}, | ||
| 85 | "policies": {}, | ||
| 86 | "checkConstraints": {}, | ||
| 87 | "isRLSEnabled": false | ||
| 88 | }, | ||
| 89 | "public.organizations": { | ||
| 90 | "name": "organizations", | ||
| 91 | "schema": "", | ||
| 92 | "columns": { | ||
| 93 | "id": { | ||
| 94 | "name": "id", | ||
| 95 | "type": "text", | ||
| 96 | "primaryKey": true, | ||
| 97 | "notNull": true | ||
| 98 | }, | ||
| 99 | "name": { | ||
| 100 | "name": "name", | ||
| 101 | "type": "text", | ||
| 102 | "primaryKey": false, | ||
| 103 | "notNull": true | ||
| 104 | }, | ||
| 105 | "slug": { | ||
| 106 | "name": "slug", | ||
| 107 | "type": "text", | ||
| 108 | "primaryKey": false, | ||
| 109 | "notNull": true | ||
| 110 | }, | ||
| 111 | "created_at": { | ||
| 112 | "name": "created_at", | ||
| 113 | "type": "timestamp with time zone", | ||
| 114 | "primaryKey": false, | ||
| 115 | "notNull": true, | ||
| 116 | "default": "now()" | ||
| 117 | } | ||
| 118 | }, | ||
| 119 | "indexes": { | ||
| 120 | "organizations_slug_unique": { | ||
| 121 | "name": "organizations_slug_unique", | ||
| 122 | "columns": [ | ||
| 123 | { | ||
| 124 | "expression": "slug", | ||
| 125 | "isExpression": false, | ||
| 126 | "asc": true, | ||
| 127 | "nulls": "last" | ||
| 128 | } | ||
| 129 | ], | ||
| 130 | "isUnique": true, | ||
| 131 | "concurrently": false, | ||
| 132 | "method": "btree", | ||
| 133 | "with": {} | ||
| 134 | } | ||
| 135 | }, | ||
| 136 | "foreignKeys": {}, | ||
| 137 | "compositePrimaryKeys": {}, | ||
| 138 | "uniqueConstraints": {}, | ||
| 139 | "policies": {}, | ||
| 140 | "checkConstraints": {}, | ||
| 141 | "isRLSEnabled": false | ||
| 142 | }, | ||
| 143 | "public.blob": { | ||
| 144 | "name": "blob", | ||
| 145 | "schema": "", | ||
| 146 | "columns": { | ||
| 147 | "namespace": { | ||
| 148 | "name": "namespace", | ||
| 149 | "type": "varchar(255)", | ||
| 150 | "primaryKey": false, | ||
| 151 | "notNull": true | ||
| 152 | }, | ||
| 153 | "key": { | ||
| 154 | "name": "key", | ||
| 155 | "type": "varchar(255)", | ||
| 156 | "primaryKey": false, | ||
| 157 | "notNull": true | ||
| 158 | }, | ||
| 159 | "value": { | ||
| 160 | "name": "value", | ||
| 161 | "type": "text", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true | ||
| 164 | }, | ||
| 165 | "row_id": { | ||
| 166 | "name": "row_id", | ||
| 167 | "type": "varchar(255)", | ||
| 168 | "primaryKey": true, | ||
| 169 | "notNull": true | ||
| 170 | }, | ||
| 171 | "id": { | ||
| 172 | "name": "id", | ||
| 173 | "type": "varchar(255)", | ||
| 174 | "primaryKey": false, | ||
| 175 | "notNull": true | ||
| 176 | } | ||
| 177 | }, | ||
| 178 | "indexes": { | ||
| 179 | "blob_id_uidx": { | ||
| 180 | "name": "blob_id_uidx", | ||
| 181 | "columns": [ | ||
| 182 | { | ||
| 183 | "expression": "id", | ||
| 184 | "isExpression": false, | ||
| 185 | "asc": true, | ||
| 186 | "nulls": "last" | ||
| 187 | } | ||
| 188 | ], | ||
| 189 | "isUnique": true, | ||
| 190 | "concurrently": false, | ||
| 191 | "method": "btree", | ||
| 192 | "with": {} | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "foreignKeys": {}, | ||
| 196 | "compositePrimaryKeys": {}, | ||
| 197 | "uniqueConstraints": {}, | ||
| 198 | "policies": {}, | ||
| 199 | "checkConstraints": {}, | ||
| 200 | "isRLSEnabled": false | ||
| 201 | }, | ||
| 202 | "public.connection": { | ||
| 203 | "name": "connection", | ||
| 204 | "schema": "", | ||
| 205 | "columns": { | ||
| 206 | "integration": { | ||
| 207 | "name": "integration", | ||
| 208 | "type": "varchar(255)", | ||
| 209 | "primaryKey": false, | ||
| 210 | "notNull": true | ||
| 211 | }, | ||
| 212 | "name": { | ||
| 213 | "name": "name", | ||
| 214 | "type": "varchar(255)", | ||
| 215 | "primaryKey": false, | ||
| 216 | "notNull": true | ||
| 217 | }, | ||
| 218 | "template": { | ||
| 219 | "name": "template", | ||
| 220 | "type": "text", | ||
| 221 | "primaryKey": false, | ||
| 222 | "notNull": true | ||
| 223 | }, | ||
| 224 | "provider": { | ||
| 225 | "name": "provider", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true | ||
| 229 | }, | ||
| 230 | "item_ids": { | ||
| 231 | "name": "item_ids", | ||
| 232 | "type": "json", | ||
| 233 | "primaryKey": false, | ||
| 234 | "notNull": true | ||
| 235 | }, | ||
| 236 | "identity_label": { | ||
| 237 | "name": "identity_label", | ||
| 238 | "type": "text", | ||
| 239 | "primaryKey": false, | ||
| 240 | "notNull": false | ||
| 241 | }, | ||
| 242 | "description": { | ||
| 243 | "name": "description", | ||
| 244 | "type": "text", | ||
| 245 | "primaryKey": false, | ||
| 246 | "notNull": false | ||
| 247 | }, | ||
| 248 | "last_health": { | ||
| 249 | "name": "last_health", | ||
| 250 | "type": "json", | ||
| 251 | "primaryKey": false, | ||
| 252 | "notNull": false | ||
| 253 | }, | ||
| 254 | "tools_synced_at": { | ||
| 255 | "name": "tools_synced_at", | ||
| 256 | "type": "bigint", | ||
| 257 | "primaryKey": false, | ||
| 258 | "notNull": false | ||
| 259 | }, | ||
| 260 | "oauth_client": { | ||
| 261 | "name": "oauth_client", | ||
| 262 | "type": "text", | ||
| 263 | "primaryKey": false, | ||
| 264 | "notNull": false | ||
| 265 | }, | ||
| 266 | "oauth_client_owner": { | ||
| 267 | "name": "oauth_client_owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": false | ||
| 271 | }, | ||
| 272 | "refresh_item_id": { | ||
| 273 | "name": "refresh_item_id", | ||
| 274 | "type": "text", | ||
| 275 | "primaryKey": false, | ||
| 276 | "notNull": false | ||
| 277 | }, | ||
| 278 | "expires_at": { | ||
| 279 | "name": "expires_at", | ||
| 280 | "type": "bigint", | ||
| 281 | "primaryKey": false, | ||
| 282 | "notNull": false | ||
| 283 | }, | ||
| 284 | "oauth_scope": { | ||
| 285 | "name": "oauth_scope", | ||
| 286 | "type": "text", | ||
| 287 | "primaryKey": false, | ||
| 288 | "notNull": false | ||
| 289 | }, | ||
| 290 | "oauth_token_url": { | ||
| 291 | "name": "oauth_token_url", | ||
| 292 | "type": "text", | ||
| 293 | "primaryKey": false, | ||
| 294 | "notNull": false | ||
| 295 | }, | ||
| 296 | "provider_state": { | ||
| 297 | "name": "provider_state", | ||
| 298 | "type": "json", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": false | ||
| 301 | }, | ||
| 302 | "created_at": { | ||
| 303 | "name": "created_at", | ||
| 304 | "type": "timestamp", | ||
| 305 | "primaryKey": false, | ||
| 306 | "notNull": true | ||
| 307 | }, | ||
| 308 | "updated_at": { | ||
| 309 | "name": "updated_at", | ||
| 310 | "type": "timestamp", | ||
| 311 | "primaryKey": false, | ||
| 312 | "notNull": true | ||
| 313 | }, | ||
| 314 | "row_id": { | ||
| 315 | "name": "row_id", | ||
| 316 | "type": "varchar(255)", | ||
| 317 | "primaryKey": true, | ||
| 318 | "notNull": true | ||
| 319 | }, | ||
| 320 | "tenant": { | ||
| 321 | "name": "tenant", | ||
| 322 | "type": "varchar(255)", | ||
| 323 | "primaryKey": false, | ||
| 324 | "notNull": true | ||
| 325 | }, | ||
| 326 | "owner": { | ||
| 327 | "name": "owner", | ||
| 328 | "type": "varchar(255)", | ||
| 329 | "primaryKey": false, | ||
| 330 | "notNull": true | ||
| 331 | }, | ||
| 332 | "subject": { | ||
| 333 | "name": "subject", | ||
| 334 | "type": "varchar(255)", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true | ||
| 337 | } | ||
| 338 | }, | ||
| 339 | "indexes": { | ||
| 340 | "connection_uidx": { | ||
| 341 | "name": "connection_uidx", | ||
| 342 | "columns": [ | ||
| 343 | { | ||
| 344 | "expression": "tenant", | ||
| 345 | "isExpression": false, | ||
| 346 | "asc": true, | ||
| 347 | "nulls": "last" | ||
| 348 | }, | ||
| 349 | { | ||
| 350 | "expression": "owner", | ||
| 351 | "isExpression": false, | ||
| 352 | "asc": true, | ||
| 353 | "nulls": "last" | ||
| 354 | }, | ||
| 355 | { | ||
| 356 | "expression": "subject", | ||
| 357 | "isExpression": false, | ||
| 358 | "asc": true, | ||
| 359 | "nulls": "last" | ||
| 360 | }, | ||
| 361 | { | ||
| 362 | "expression": "integration", | ||
| 363 | "isExpression": false, | ||
| 364 | "asc": true, | ||
| 365 | "nulls": "last" | ||
| 366 | }, | ||
| 367 | { | ||
| 368 | "expression": "name", | ||
| 369 | "isExpression": false, | ||
| 370 | "asc": true, | ||
| 371 | "nulls": "last" | ||
| 372 | } | ||
| 373 | ], | ||
| 374 | "isUnique": true, | ||
| 375 | "concurrently": false, | ||
| 376 | "method": "btree", | ||
| 377 | "with": {} | ||
| 378 | } | ||
| 379 | }, | ||
| 380 | "foreignKeys": {}, | ||
| 381 | "compositePrimaryKeys": {}, | ||
| 382 | "uniqueConstraints": {}, | ||
| 383 | "policies": {}, | ||
| 384 | "checkConstraints": {}, | ||
| 385 | "isRLSEnabled": false | ||
| 386 | }, | ||
| 387 | "public.definition": { | ||
| 388 | "name": "definition", | ||
| 389 | "schema": "", | ||
| 390 | "columns": { | ||
| 391 | "integration": { | ||
| 392 | "name": "integration", | ||
| 393 | "type": "varchar(255)", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true | ||
| 396 | }, | ||
| 397 | "connection": { | ||
| 398 | "name": "connection", | ||
| 399 | "type": "varchar(255)", | ||
| 400 | "primaryKey": false, | ||
| 401 | "notNull": true | ||
| 402 | }, | ||
| 403 | "plugin_id": { | ||
| 404 | "name": "plugin_id", | ||
| 405 | "type": "text", | ||
| 406 | "primaryKey": false, | ||
| 407 | "notNull": true | ||
| 408 | }, | ||
| 409 | "name": { | ||
| 410 | "name": "name", | ||
| 411 | "type": "text", | ||
| 412 | "primaryKey": false, | ||
| 413 | "notNull": true | ||
| 414 | }, | ||
| 415 | "schema": { | ||
| 416 | "name": "schema", | ||
| 417 | "type": "json", | ||
| 418 | "primaryKey": false, | ||
| 419 | "notNull": true | ||
| 420 | }, | ||
| 421 | "created_at": { | ||
| 422 | "name": "created_at", | ||
| 423 | "type": "timestamp", | ||
| 424 | "primaryKey": false, | ||
| 425 | "notNull": true | ||
| 426 | }, | ||
| 427 | "row_id": { | ||
| 428 | "name": "row_id", | ||
| 429 | "type": "varchar(255)", | ||
| 430 | "primaryKey": true, | ||
| 431 | "notNull": true | ||
| 432 | }, | ||
| 433 | "tenant": { | ||
| 434 | "name": "tenant", | ||
| 435 | "type": "varchar(255)", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": true | ||
| 438 | }, | ||
| 439 | "owner": { | ||
| 440 | "name": "owner", | ||
| 441 | "type": "varchar(255)", | ||
| 442 | "primaryKey": false, | ||
| 443 | "notNull": true | ||
| 444 | }, | ||
| 445 | "subject": { | ||
| 446 | "name": "subject", | ||
| 447 | "type": "varchar(255)", | ||
| 448 | "primaryKey": false, | ||
| 449 | "notNull": true | ||
| 450 | } | ||
| 451 | }, | ||
| 452 | "indexes": { | ||
| 453 | "definition_uidx": { | ||
| 454 | "name": "definition_uidx", | ||
| 455 | "columns": [ | ||
| 456 | { | ||
| 457 | "expression": "tenant", | ||
| 458 | "isExpression": false, | ||
| 459 | "asc": true, | ||
| 460 | "nulls": "last" | ||
| 461 | }, | ||
| 462 | { | ||
| 463 | "expression": "owner", | ||
| 464 | "isExpression": false, | ||
| 465 | "asc": true, | ||
| 466 | "nulls": "last" | ||
| 467 | }, | ||
| 468 | { | ||
| 469 | "expression": "subject", | ||
| 470 | "isExpression": false, | ||
| 471 | "asc": true, | ||
| 472 | "nulls": "last" | ||
| 473 | }, | ||
| 474 | { | ||
| 475 | "expression": "integration", | ||
| 476 | "isExpression": false, | ||
| 477 | "asc": true, | ||
| 478 | "nulls": "last" | ||
| 479 | }, | ||
| 480 | { | ||
| 481 | "expression": "connection", | ||
| 482 | "isExpression": false, | ||
| 483 | "asc": true, | ||
| 484 | "nulls": "last" | ||
| 485 | }, | ||
| 486 | { | ||
| 487 | "expression": "name", | ||
| 488 | "isExpression": false, | ||
| 489 | "asc": true, | ||
| 490 | "nulls": "last" | ||
| 491 | } | ||
| 492 | ], | ||
| 493 | "isUnique": true, | ||
| 494 | "concurrently": false, | ||
| 495 | "method": "btree", | ||
| 496 | "with": {} | ||
| 497 | } | ||
| 498 | }, | ||
| 499 | "foreignKeys": {}, | ||
| 500 | "compositePrimaryKeys": {}, | ||
| 501 | "uniqueConstraints": {}, | ||
| 502 | "policies": {}, | ||
| 503 | "checkConstraints": {}, | ||
| 504 | "isRLSEnabled": false | ||
| 505 | }, | ||
| 506 | "public.integration": { | ||
| 507 | "name": "integration", | ||
| 508 | "schema": "", | ||
| 509 | "columns": { | ||
| 510 | "slug": { | ||
| 511 | "name": "slug", | ||
| 512 | "type": "varchar(255)", | ||
| 513 | "primaryKey": false, | ||
| 514 | "notNull": true | ||
| 515 | }, | ||
| 516 | "plugin_id": { | ||
| 517 | "name": "plugin_id", | ||
| 518 | "type": "text", | ||
| 519 | "primaryKey": false, | ||
| 520 | "notNull": true | ||
| 521 | }, | ||
| 522 | "name": { | ||
| 523 | "name": "name", | ||
| 524 | "type": "text", | ||
| 525 | "primaryKey": false, | ||
| 526 | "notNull": false | ||
| 527 | }, | ||
| 528 | "description": { | ||
| 529 | "name": "description", | ||
| 530 | "type": "text", | ||
| 531 | "primaryKey": false, | ||
| 532 | "notNull": false | ||
| 533 | }, | ||
| 534 | "config_revised_at": { | ||
| 535 | "name": "config_revised_at", | ||
| 536 | "type": "bigint", | ||
| 537 | "primaryKey": false, | ||
| 538 | "notNull": false | ||
| 539 | }, | ||
| 540 | "config": { | ||
| 541 | "name": "config", | ||
| 542 | "type": "json", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": false | ||
| 545 | }, | ||
| 546 | "health_check": { | ||
| 547 | "name": "health_check", | ||
| 548 | "type": "json", | ||
| 549 | "primaryKey": false, | ||
| 550 | "notNull": false | ||
| 551 | }, | ||
| 552 | "can_remove": { | ||
| 553 | "name": "can_remove", | ||
| 554 | "type": "boolean", | ||
| 555 | "primaryKey": false, | ||
| 556 | "notNull": true, | ||
| 557 | "default": true | ||
| 558 | }, | ||
| 559 | "can_refresh": { | ||
| 560 | "name": "can_refresh", | ||
| 561 | "type": "boolean", | ||
| 562 | "primaryKey": false, | ||
| 563 | "notNull": true, | ||
| 564 | "default": false | ||
| 565 | }, | ||
| 566 | "created_at": { | ||
| 567 | "name": "created_at", | ||
| 568 | "type": "timestamp", | ||
| 569 | "primaryKey": false, | ||
| 570 | "notNull": true | ||
| 571 | }, | ||
| 572 | "updated_at": { | ||
| 573 | "name": "updated_at", | ||
| 574 | "type": "timestamp", | ||
| 575 | "primaryKey": false, | ||
| 576 | "notNull": true | ||
| 577 | }, | ||
| 578 | "row_id": { | ||
| 579 | "name": "row_id", | ||
| 580 | "type": "varchar(255)", | ||
| 581 | "primaryKey": true, | ||
| 582 | "notNull": true | ||
| 583 | }, | ||
| 584 | "tenant": { | ||
| 585 | "name": "tenant", | ||
| 586 | "type": "varchar(255)", | ||
| 587 | "primaryKey": false, | ||
| 588 | "notNull": true | ||
| 589 | } | ||
| 590 | }, | ||
| 591 | "indexes": { | ||
| 592 | "integration_uidx": { | ||
| 593 | "name": "integration_uidx", | ||
| 594 | "columns": [ | ||
| 595 | { | ||
| 596 | "expression": "tenant", | ||
| 597 | "isExpression": false, | ||
| 598 | "asc": true, | ||
| 599 | "nulls": "last" | ||
| 600 | }, | ||
| 601 | { | ||
| 602 | "expression": "slug", | ||
| 603 | "isExpression": false, | ||
| 604 | "asc": true, | ||
| 605 | "nulls": "last" | ||
| 606 | } | ||
| 607 | ], | ||
| 608 | "isUnique": true, | ||
| 609 | "concurrently": false, | ||
| 610 | "method": "btree", | ||
| 611 | "with": {} | ||
| 612 | } | ||
| 613 | }, | ||
| 614 | "foreignKeys": {}, | ||
| 615 | "compositePrimaryKeys": {}, | ||
| 616 | "uniqueConstraints": {}, | ||
| 617 | "policies": {}, | ||
| 618 | "checkConstraints": {}, | ||
| 619 | "isRLSEnabled": false | ||
| 620 | }, | ||
| 621 | "public.oauth_client": { | ||
| 622 | "name": "oauth_client", | ||
| 623 | "schema": "", | ||
| 624 | "columns": { | ||
| 625 | "slug": { | ||
| 626 | "name": "slug", | ||
| 627 | "type": "varchar(255)", | ||
| 628 | "primaryKey": false, | ||
| 629 | "notNull": true | ||
| 630 | }, | ||
| 631 | "authorization_url": { | ||
| 632 | "name": "authorization_url", | ||
| 633 | "type": "text", | ||
| 634 | "primaryKey": false, | ||
| 635 | "notNull": true | ||
| 636 | }, | ||
| 637 | "token_url": { | ||
| 638 | "name": "token_url", | ||
| 639 | "type": "text", | ||
| 640 | "primaryKey": false, | ||
| 641 | "notNull": true | ||
| 642 | }, | ||
| 643 | "grant": { | ||
| 644 | "name": "grant", | ||
| 645 | "type": "text", | ||
| 646 | "primaryKey": false, | ||
| 647 | "notNull": true | ||
| 648 | }, | ||
| 649 | "client_id": { | ||
| 650 | "name": "client_id", | ||
| 651 | "type": "text", | ||
| 652 | "primaryKey": false, | ||
| 653 | "notNull": true | ||
| 654 | }, | ||
| 655 | "client_secret_item_id": { | ||
| 656 | "name": "client_secret_item_id", | ||
| 657 | "type": "text", | ||
| 658 | "primaryKey": false, | ||
| 659 | "notNull": false | ||
| 660 | }, | ||
| 661 | "resource": { | ||
| 662 | "name": "resource", | ||
| 663 | "type": "text", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": false | ||
| 666 | }, | ||
| 667 | "origin_kind": { | ||
| 668 | "name": "origin_kind", | ||
| 669 | "type": "text", | ||
| 670 | "primaryKey": false, | ||
| 671 | "notNull": false | ||
| 672 | }, | ||
| 673 | "origin_integration": { | ||
| 674 | "name": "origin_integration", | ||
| 675 | "type": "text", | ||
| 676 | "primaryKey": false, | ||
| 677 | "notNull": false | ||
| 678 | }, | ||
| 679 | "origin_issuer": { | ||
| 680 | "name": "origin_issuer", | ||
| 681 | "type": "text", | ||
| 682 | "primaryKey": false, | ||
| 683 | "notNull": false | ||
| 684 | }, | ||
| 685 | "created_at": { | ||
| 686 | "name": "created_at", | ||
| 687 | "type": "timestamp", | ||
| 688 | "primaryKey": false, | ||
| 689 | "notNull": true | ||
| 690 | }, | ||
| 691 | "row_id": { | ||
| 692 | "name": "row_id", | ||
| 693 | "type": "varchar(255)", | ||
| 694 | "primaryKey": true, | ||
| 695 | "notNull": true | ||
| 696 | }, | ||
| 697 | "tenant": { | ||
| 698 | "name": "tenant", | ||
| 699 | "type": "varchar(255)", | ||
| 700 | "primaryKey": false, | ||
| 701 | "notNull": true | ||
| 702 | }, | ||
| 703 | "owner": { | ||
| 704 | "name": "owner", | ||
| 705 | "type": "varchar(255)", | ||
| 706 | "primaryKey": false, | ||
| 707 | "notNull": true | ||
| 708 | }, | ||
| 709 | "subject": { | ||
| 710 | "name": "subject", | ||
| 711 | "type": "varchar(255)", | ||
| 712 | "primaryKey": false, | ||
| 713 | "notNull": true | ||
| 714 | } | ||
| 715 | }, | ||
| 716 | "indexes": { | ||
| 717 | "oauth_client_uidx": { | ||
| 718 | "name": "oauth_client_uidx", | ||
| 719 | "columns": [ | ||
| 720 | { | ||
| 721 | "expression": "tenant", | ||
| 722 | "isExpression": false, | ||
| 723 | "asc": true, | ||
| 724 | "nulls": "last" | ||
| 725 | }, | ||
| 726 | { | ||
| 727 | "expression": "owner", | ||
| 728 | "isExpression": false, | ||
| 729 | "asc": true, | ||
| 730 | "nulls": "last" | ||
| 731 | }, | ||
| 732 | { | ||
| 733 | "expression": "subject", | ||
| 734 | "isExpression": false, | ||
| 735 | "asc": true, | ||
| 736 | "nulls": "last" | ||
| 737 | }, | ||
| 738 | { | ||
| 739 | "expression": "slug", | ||
| 740 | "isExpression": false, | ||
| 741 | "asc": true, | ||
| 742 | "nulls": "last" | ||
| 743 | } | ||
| 744 | ], | ||
| 745 | "isUnique": true, | ||
| 746 | "concurrently": false, | ||
| 747 | "method": "btree", | ||
| 748 | "with": {} | ||
| 749 | } | ||
| 750 | }, | ||
| 751 | "foreignKeys": {}, | ||
| 752 | "compositePrimaryKeys": {}, | ||
| 753 | "uniqueConstraints": {}, | ||
| 754 | "policies": {}, | ||
| 755 | "checkConstraints": {}, | ||
| 756 | "isRLSEnabled": false | ||
| 757 | }, | ||
| 758 | "public.oauth_session": { | ||
| 759 | "name": "oauth_session", | ||
| 760 | "schema": "", | ||
| 761 | "columns": { | ||
| 762 | "state": { | ||
| 763 | "name": "state", | ||
| 764 | "type": "varchar(255)", | ||
| 765 | "primaryKey": false, | ||
| 766 | "notNull": true | ||
| 767 | }, | ||
| 768 | "client_slug": { | ||
| 769 | "name": "client_slug", | ||
| 770 | "type": "text", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true | ||
| 773 | }, | ||
| 774 | "integration": { | ||
| 775 | "name": "integration", | ||
| 776 | "type": "text", | ||
| 777 | "primaryKey": false, | ||
| 778 | "notNull": true | ||
| 779 | }, | ||
| 780 | "name": { | ||
| 781 | "name": "name", | ||
| 782 | "type": "text", | ||
| 783 | "primaryKey": false, | ||
| 784 | "notNull": true | ||
| 785 | }, | ||
| 786 | "template": { | ||
| 787 | "name": "template", | ||
| 788 | "type": "text", | ||
| 789 | "primaryKey": false, | ||
| 790 | "notNull": true | ||
| 791 | }, | ||
| 792 | "redirect_url": { | ||
| 793 | "name": "redirect_url", | ||
| 794 | "type": "text", | ||
| 795 | "primaryKey": false, | ||
| 796 | "notNull": true | ||
| 797 | }, | ||
| 798 | "pkce_verifier": { | ||
| 799 | "name": "pkce_verifier", | ||
| 800 | "type": "text", | ||
| 801 | "primaryKey": false, | ||
| 802 | "notNull": false | ||
| 803 | }, | ||
| 804 | "identity_label": { | ||
| 805 | "name": "identity_label", | ||
| 806 | "type": "text", | ||
| 807 | "primaryKey": false, | ||
| 808 | "notNull": false | ||
| 809 | }, | ||
| 810 | "payload": { | ||
| 811 | "name": "payload", | ||
| 812 | "type": "json", | ||
| 813 | "primaryKey": false, | ||
| 814 | "notNull": true | ||
| 815 | }, | ||
| 816 | "expires_at": { | ||
| 817 | "name": "expires_at", | ||
| 818 | "type": "bigint", | ||
| 819 | "primaryKey": false, | ||
| 820 | "notNull": true | ||
| 821 | }, | ||
| 822 | "created_at": { | ||
| 823 | "name": "created_at", | ||
| 824 | "type": "timestamp", | ||
| 825 | "primaryKey": false, | ||
| 826 | "notNull": true | ||
| 827 | }, | ||
| 828 | "row_id": { | ||
| 829 | "name": "row_id", | ||
| 830 | "type": "varchar(255)", | ||
| 831 | "primaryKey": true, | ||
| 832 | "notNull": true | ||
| 833 | }, | ||
| 834 | "tenant": { | ||
| 835 | "name": "tenant", | ||
| 836 | "type": "varchar(255)", | ||
| 837 | "primaryKey": false, | ||
| 838 | "notNull": true | ||
| 839 | }, | ||
| 840 | "owner": { | ||
| 841 | "name": "owner", | ||
| 842 | "type": "varchar(255)", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true | ||
| 845 | }, | ||
| 846 | "subject": { | ||
| 847 | "name": "subject", | ||
| 848 | "type": "varchar(255)", | ||
| 849 | "primaryKey": false, | ||
| 850 | "notNull": true | ||
| 851 | } | ||
| 852 | }, | ||
| 853 | "indexes": { | ||
| 854 | "oauth_session_uidx": { | ||
| 855 | "name": "oauth_session_uidx", | ||
| 856 | "columns": [ | ||
| 857 | { | ||
| 858 | "expression": "tenant", | ||
| 859 | "isExpression": false, | ||
| 860 | "asc": true, | ||
| 861 | "nulls": "last" | ||
| 862 | }, | ||
| 863 | { | ||
| 864 | "expression": "state", | ||
| 865 | "isExpression": false, | ||
| 866 | "asc": true, | ||
| 867 | "nulls": "last" | ||
| 868 | } | ||
| 869 | ], | ||
| 870 | "isUnique": true, | ||
| 871 | "concurrently": false, | ||
| 872 | "method": "btree", | ||
| 873 | "with": {} | ||
| 874 | } | ||
| 875 | }, | ||
| 876 | "foreignKeys": {}, | ||
| 877 | "compositePrimaryKeys": {}, | ||
| 878 | "uniqueConstraints": {}, | ||
| 879 | "policies": {}, | ||
| 880 | "checkConstraints": {}, | ||
| 881 | "isRLSEnabled": false | ||
| 882 | }, | ||
| 883 | "public.plugin_storage": { | ||
| 884 | "name": "plugin_storage", | ||
| 885 | "schema": "", | ||
| 886 | "columns": { | ||
| 887 | "plugin_id": { | ||
| 888 | "name": "plugin_id", | ||
| 889 | "type": "varchar(255)", | ||
| 890 | "primaryKey": false, | ||
| 891 | "notNull": true | ||
| 892 | }, | ||
| 893 | "collection": { | ||
| 894 | "name": "collection", | ||
| 895 | "type": "varchar(255)", | ||
| 896 | "primaryKey": false, | ||
| 897 | "notNull": true | ||
| 898 | }, | ||
| 899 | "key": { | ||
| 900 | "name": "key", | ||
| 901 | "type": "varchar(255)", | ||
| 902 | "primaryKey": false, | ||
| 903 | "notNull": true | ||
| 904 | }, | ||
| 905 | "data": { | ||
| 906 | "name": "data", | ||
| 907 | "type": "json", | ||
| 908 | "primaryKey": false, | ||
| 909 | "notNull": true | ||
| 910 | }, | ||
| 911 | "created_at": { | ||
| 912 | "name": "created_at", | ||
| 913 | "type": "timestamp", | ||
| 914 | "primaryKey": false, | ||
| 915 | "notNull": true | ||
| 916 | }, | ||
| 917 | "updated_at": { | ||
| 918 | "name": "updated_at", | ||
| 919 | "type": "timestamp", | ||
| 920 | "primaryKey": false, | ||
| 921 | "notNull": true | ||
| 922 | }, | ||
| 923 | "row_id": { | ||
| 924 | "name": "row_id", | ||
| 925 | "type": "varchar(255)", | ||
| 926 | "primaryKey": true, | ||
| 927 | "notNull": true | ||
| 928 | }, | ||
| 929 | "tenant": { | ||
| 930 | "name": "tenant", | ||
| 931 | "type": "varchar(255)", | ||
| 932 | "primaryKey": false, | ||
| 933 | "notNull": true | ||
| 934 | }, | ||
| 935 | "owner": { | ||
| 936 | "name": "owner", | ||
| 937 | "type": "varchar(255)", | ||
| 938 | "primaryKey": false, | ||
| 939 | "notNull": true | ||
| 940 | }, | ||
| 941 | "subject": { | ||
| 942 | "name": "subject", | ||
| 943 | "type": "varchar(255)", | ||
| 944 | "primaryKey": false, | ||
| 945 | "notNull": true | ||
| 946 | } | ||
| 947 | }, | ||
| 948 | "indexes": { | ||
| 949 | "plugin_storage_uidx": { | ||
| 950 | "name": "plugin_storage_uidx", | ||
| 951 | "columns": [ | ||
| 952 | { | ||
| 953 | "expression": "tenant", | ||
| 954 | "isExpression": false, | ||
| 955 | "asc": true, | ||
| 956 | "nulls": "last" | ||
| 957 | }, | ||
| 958 | { | ||
| 959 | "expression": "owner", | ||
| 960 | "isExpression": false, | ||
| 961 | "asc": true, | ||
| 962 | "nulls": "last" | ||
| 963 | }, | ||
| 964 | { | ||
| 965 | "expression": "subject", | ||
| 966 | "isExpression": false, | ||
| 967 | "asc": true, | ||
| 968 | "nulls": "last" | ||
| 969 | }, | ||
| 970 | { | ||
| 971 | "expression": "plugin_id", | ||
| 972 | "isExpression": false, | ||
| 973 | "asc": true, | ||
| 974 | "nulls": "last" | ||
| 975 | }, | ||
| 976 | { | ||
| 977 | "expression": "collection", | ||
| 978 | "isExpression": false, | ||
| 979 | "asc": true, | ||
| 980 | "nulls": "last" | ||
| 981 | }, | ||
| 982 | { | ||
| 983 | "expression": "key", | ||
| 984 | "isExpression": false, | ||
| 985 | "asc": true, | ||
| 986 | "nulls": "last" | ||
| 987 | } | ||
| 988 | ], | ||
| 989 | "isUnique": true, | ||
| 990 | "concurrently": false, | ||
| 991 | "method": "btree", | ||
| 992 | "with": {} | ||
| 993 | } | ||
| 994 | }, | ||
| 995 | "foreignKeys": {}, | ||
| 996 | "compositePrimaryKeys": {}, | ||
| 997 | "uniqueConstraints": {}, | ||
| 998 | "policies": {}, | ||
| 999 | "checkConstraints": {}, | ||
| 1000 | "isRLSEnabled": false | ||
| 1001 | }, | ||
| 1002 | "public.private_executor_cloud_settings": { | ||
| 1003 | "name": "private_executor_cloud_settings", | ||
| 1004 | "schema": "", | ||
| 1005 | "columns": { | ||
| 1006 | "id": { | ||
| 1007 | "name": "id", | ||
| 1008 | "type": "varchar(255)", | ||
| 1009 | "primaryKey": true, | ||
| 1010 | "notNull": true | ||
| 1011 | }, | ||
| 1012 | "version": { | ||
| 1013 | "name": "version", | ||
| 1014 | "type": "varchar(255)", | ||
| 1015 | "primaryKey": false, | ||
| 1016 | "notNull": true, | ||
| 1017 | "default": "'1.0.0'" | ||
| 1018 | } | ||
| 1019 | }, | ||
| 1020 | "indexes": {}, | ||
| 1021 | "foreignKeys": {}, | ||
| 1022 | "compositePrimaryKeys": {}, | ||
| 1023 | "uniqueConstraints": {}, | ||
| 1024 | "policies": {}, | ||
| 1025 | "checkConstraints": {}, | ||
| 1026 | "isRLSEnabled": false | ||
| 1027 | }, | ||
| 1028 | "public.tool": { | ||
| 1029 | "name": "tool", | ||
| 1030 | "schema": "", | ||
| 1031 | "columns": { | ||
| 1032 | "integration": { | ||
| 1033 | "name": "integration", | ||
| 1034 | "type": "varchar(255)", | ||
| 1035 | "primaryKey": false, | ||
| 1036 | "notNull": true | ||
| 1037 | }, | ||
| 1038 | "connection": { | ||
| 1039 | "name": "connection", | ||
| 1040 | "type": "varchar(255)", | ||
| 1041 | "primaryKey": false, | ||
| 1042 | "notNull": true | ||
| 1043 | }, | ||
| 1044 | "plugin_id": { | ||
| 1045 | "name": "plugin_id", | ||
| 1046 | "type": "text", | ||
| 1047 | "primaryKey": false, | ||
| 1048 | "notNull": true | ||
| 1049 | }, | ||
| 1050 | "name": { | ||
| 1051 | "name": "name", | ||
| 1052 | "type": "varchar(255)", | ||
| 1053 | "primaryKey": false, | ||
| 1054 | "notNull": true | ||
| 1055 | }, | ||
| 1056 | "description": { | ||
| 1057 | "name": "description", | ||
| 1058 | "type": "text", | ||
| 1059 | "primaryKey": false, | ||
| 1060 | "notNull": true | ||
| 1061 | }, | ||
| 1062 | "input_schema": { | ||
| 1063 | "name": "input_schema", | ||
| 1064 | "type": "json", | ||
| 1065 | "primaryKey": false, | ||
| 1066 | "notNull": false | ||
| 1067 | }, | ||
| 1068 | "output_schema": { | ||
| 1069 | "name": "output_schema", | ||
| 1070 | "type": "json", | ||
| 1071 | "primaryKey": false, | ||
| 1072 | "notNull": false | ||
| 1073 | }, | ||
| 1074 | "annotations": { | ||
| 1075 | "name": "annotations", | ||
| 1076 | "type": "json", | ||
| 1077 | "primaryKey": false, | ||
| 1078 | "notNull": false | ||
| 1079 | }, | ||
| 1080 | "created_at": { | ||
| 1081 | "name": "created_at", | ||
| 1082 | "type": "timestamp", | ||
| 1083 | "primaryKey": false, | ||
| 1084 | "notNull": true | ||
| 1085 | }, | ||
| 1086 | "updated_at": { | ||
| 1087 | "name": "updated_at", | ||
| 1088 | "type": "timestamp", | ||
| 1089 | "primaryKey": false, | ||
| 1090 | "notNull": true | ||
| 1091 | }, | ||
| 1092 | "row_id": { | ||
| 1093 | "name": "row_id", | ||
| 1094 | "type": "varchar(255)", | ||
| 1095 | "primaryKey": true, | ||
| 1096 | "notNull": true | ||
| 1097 | }, | ||
| 1098 | "tenant": { | ||
| 1099 | "name": "tenant", | ||
| 1100 | "type": "varchar(255)", | ||
| 1101 | "primaryKey": false, | ||
| 1102 | "notNull": true | ||
| 1103 | }, | ||
| 1104 | "owner": { | ||
| 1105 | "name": "owner", | ||
| 1106 | "type": "varchar(255)", | ||
| 1107 | "primaryKey": false, | ||
| 1108 | "notNull": true | ||
| 1109 | }, | ||
| 1110 | "subject": { | ||
| 1111 | "name": "subject", | ||
| 1112 | "type": "varchar(255)", | ||
| 1113 | "primaryKey": false, | ||
| 1114 | "notNull": true | ||
| 1115 | } | ||
| 1116 | }, | ||
| 1117 | "indexes": { | ||
| 1118 | "tool_uidx": { | ||
| 1119 | "name": "tool_uidx", | ||
| 1120 | "columns": [ | ||
| 1121 | { | ||
| 1122 | "expression": "tenant", | ||
| 1123 | "isExpression": false, | ||
| 1124 | "asc": true, | ||
| 1125 | "nulls": "last" | ||
| 1126 | }, | ||
| 1127 | { | ||
| 1128 | "expression": "owner", | ||
| 1129 | "isExpression": false, | ||
| 1130 | "asc": true, | ||
| 1131 | "nulls": "last" | ||
| 1132 | }, | ||
| 1133 | { | ||
| 1134 | "expression": "subject", | ||
| 1135 | "isExpression": false, | ||
| 1136 | "asc": true, | ||
| 1137 | "nulls": "last" | ||
| 1138 | }, | ||
| 1139 | { | ||
| 1140 | "expression": "integration", | ||
| 1141 | "isExpression": false, | ||
| 1142 | "asc": true, | ||
| 1143 | "nulls": "last" | ||
| 1144 | }, | ||
| 1145 | { | ||
| 1146 | "expression": "connection", | ||
| 1147 | "isExpression": false, | ||
| 1148 | "asc": true, | ||
| 1149 | "nulls": "last" | ||
| 1150 | }, | ||
| 1151 | { | ||
| 1152 | "expression": "name", | ||
| 1153 | "isExpression": false, | ||
| 1154 | "asc": true, | ||
| 1155 | "nulls": "last" | ||
| 1156 | } | ||
| 1157 | ], | ||
| 1158 | "isUnique": true, | ||
| 1159 | "concurrently": false, | ||
| 1160 | "method": "btree", | ||
| 1161 | "with": {} | ||
| 1162 | } | ||
| 1163 | }, | ||
| 1164 | "foreignKeys": {}, | ||
| 1165 | "compositePrimaryKeys": {}, | ||
| 1166 | "uniqueConstraints": {}, | ||
| 1167 | "policies": {}, | ||
| 1168 | "checkConstraints": {}, | ||
| 1169 | "isRLSEnabled": false | ||
| 1170 | }, | ||
| 1171 | "public.tool_policy": { | ||
| 1172 | "name": "tool_policy", | ||
| 1173 | "schema": "", | ||
| 1174 | "columns": { | ||
| 1175 | "id": { | ||
| 1176 | "name": "id", | ||
| 1177 | "type": "varchar(255)", | ||
| 1178 | "primaryKey": false, | ||
| 1179 | "notNull": true | ||
| 1180 | }, | ||
| 1181 | "pattern": { | ||
| 1182 | "name": "pattern", | ||
| 1183 | "type": "text", | ||
| 1184 | "primaryKey": false, | ||
| 1185 | "notNull": true | ||
| 1186 | }, | ||
| 1187 | "action": { | ||
| 1188 | "name": "action", | ||
| 1189 | "type": "text", | ||
| 1190 | "primaryKey": false, | ||
| 1191 | "notNull": true | ||
| 1192 | }, | ||
| 1193 | "position": { | ||
| 1194 | "name": "position", | ||
| 1195 | "type": "text", | ||
| 1196 | "primaryKey": false, | ||
| 1197 | "notNull": true | ||
| 1198 | }, | ||
| 1199 | "created_at": { | ||
| 1200 | "name": "created_at", | ||
| 1201 | "type": "timestamp", | ||
| 1202 | "primaryKey": false, | ||
| 1203 | "notNull": true | ||
| 1204 | }, | ||
| 1205 | "updated_at": { | ||
| 1206 | "name": "updated_at", | ||
| 1207 | "type": "timestamp", | ||
| 1208 | "primaryKey": false, | ||
| 1209 | "notNull": true | ||
| 1210 | }, | ||
| 1211 | "row_id": { | ||
| 1212 | "name": "row_id", | ||
| 1213 | "type": "varchar(255)", | ||
| 1214 | "primaryKey": true, | ||
| 1215 | "notNull": true | ||
| 1216 | }, | ||
| 1217 | "tenant": { | ||
| 1218 | "name": "tenant", | ||
| 1219 | "type": "varchar(255)", | ||
| 1220 | "primaryKey": false, | ||
| 1221 | "notNull": true | ||
| 1222 | }, | ||
| 1223 | "owner": { | ||
| 1224 | "name": "owner", | ||
| 1225 | "type": "varchar(255)", | ||
| 1226 | "primaryKey": false, | ||
| 1227 | "notNull": true | ||
| 1228 | }, | ||
| 1229 | "subject": { | ||
| 1230 | "name": "subject", | ||
| 1231 | "type": "varchar(255)", | ||
| 1232 | "primaryKey": false, | ||
| 1233 | "notNull": true | ||
| 1234 | } | ||
| 1235 | }, | ||
| 1236 | "indexes": { | ||
| 1237 | "tool_policy_uidx": { | ||
| 1238 | "name": "tool_policy_uidx", | ||
| 1239 | "columns": [ | ||
| 1240 | { | ||
| 1241 | "expression": "tenant", | ||
| 1242 | "isExpression": false, | ||
| 1243 | "asc": true, | ||
| 1244 | "nulls": "last" | ||
| 1245 | }, | ||
| 1246 | { | ||
| 1247 | "expression": "owner", | ||
| 1248 | "isExpression": false, | ||
| 1249 | "asc": true, | ||
| 1250 | "nulls": "last" | ||
| 1251 | }, | ||
| 1252 | { | ||
| 1253 | "expression": "subject", | ||
| 1254 | "isExpression": false, | ||
| 1255 | "asc": true, | ||
| 1256 | "nulls": "last" | ||
| 1257 | }, | ||
| 1258 | { | ||
| 1259 | "expression": "id", | ||
| 1260 | "isExpression": false, | ||
| 1261 | "asc": true, | ||
| 1262 | "nulls": "last" | ||
| 1263 | } | ||
| 1264 | ], | ||
| 1265 | "isUnique": true, | ||
| 1266 | "concurrently": false, | ||
| 1267 | "method": "btree", | ||
| 1268 | "with": {} | ||
| 1269 | } | ||
| 1270 | }, | ||
| 1271 | "foreignKeys": {}, | ||
| 1272 | "compositePrimaryKeys": {}, | ||
| 1273 | "uniqueConstraints": {}, | ||
| 1274 | "policies": {}, | ||
| 1275 | "checkConstraints": {}, | ||
| 1276 | "isRLSEnabled": false | ||
| 1277 | } | ||
| 1278 | }, | ||
| 1279 | "enums": {}, | ||
| 1280 | "schemas": {}, | ||
| 1281 | "sequences": {}, | ||
| 1282 | "roles": {}, | ||
| 1283 | "policies": {}, | ||
| 1284 | "views": {}, | ||
| 1285 | "_meta": { | ||
| 1286 | "columns": {}, | ||
| 1287 | "schemas": {}, | ||
| 1288 | "tables": {} | ||
| 1289 | } | ||
| 1290 | } | ||
| @@ -61,8 +61,15 @@ · 60 unmodified lines | |||
| 61 | { | 61 | { |
| 62 | "idx": 8, | 62 | "idx": 8, |
| 63 | "version": "7", | 63 | "version": "7", |
| 64 | "when": 1782957914937, | 64 | "when": 1783025157232, |
| 65 | "tag": "0008_true_lake", | 65 | "tag": "0008_slow_mephisto", |
| 66 | "breakpoints": true | ||
| 67 | }, | ||
| 68 | { | ||
| 69 | "idx": 9, | ||
| 70 | "version": "7", | ||
| 71 | "when": 1783025157233, | ||
| 72 | "tag": "0009_true_lake", | ||
| 66 | "breakpoints": true | 73 | "breakpoints": true |
| 67 | } | 74 | } |
| 68 | ] | 75 | ] |
| @@ -0,0 +1,174 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Cloud code migration: GC dead DCR `oauth_client` rows + backfill the | ||
| 3 | // surviving ones' `origin_issuer` (issue #1120, Part C). | ||
| 4 | // | ||
| 5 | // The cloud counterpart to the local libSQL boot migration | ||
| 6 | // (`sqlite-oauth-client-gc-migration` in @executor-js/sdk). It runs through the | ||
| 7 | // stamped `cloud_code_migration` ledger (see runner.ts), out-of-band via | ||
| 8 | // `scripts/migrate.ts`, with `--dry-run` support and an advisory lock. | ||
| 9 | // | ||
| 10 | // It is a CODE migration, not a numbered `.sql` one, deliberately: the DCR | ||
| 11 | // classification and the registrable-origin backfill are the SAME TypeScript | ||
| 12 | // predicates the local path and the runtime reuse lookup use | ||
| 13 | // (`classifyOAuthClientGc`, `registrableOriginOfUrl`). Re-encoding the eTLD+1 | ||
| 14 | // registrable-origin logic and the DCR heuristic as Postgres regex/SQL would be | ||
| 15 | // a second, drift-prone source of truth for a migration that DELETES user data. | ||
| 16 | // Reading the (small) oauth_client table and deciding in-process keeps exactly | ||
| 17 | // one source of truth across both hosts. | ||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | |||
| 20 | import { | ||
| 21 | classifyOAuthClientGc, | ||
| 22 | isDcrClassifiedRow, | ||
| 23 | registrableOriginOfUrl, | ||
| 24 | } from "@executor-js/sdk"; | ||
| 25 | |||
| 26 | import type { CodeMigration, CodeMigrationContext } from "./runner"; | ||
| 27 | |||
| 28 | interface OAuthClientRow { | ||
| 29 | readonly tenant: string; | ||
| 30 | readonly owner: string; | ||
| 31 | readonly slug: string; | ||
| 32 | readonly grant: string | null; | ||
| 33 | readonly resource: string | null; | ||
| 34 | readonly origin_kind: string | null; | ||
| 35 | readonly origin_issuer: string | null; | ||
| 36 | readonly token_url: string | null; | ||
| 37 | } | ||
| 38 | |||
| 39 | const readOAuthClients = (context: CodeMigrationContext): Promise<readonly OAuthClientRow[]> => | ||
| 40 | // `grant` is a reserved word in Postgres, so it must be quoted. Deterministic | ||
| 41 | // row order keeps the dry-run report + logs stable. | ||
| 42 | context.sql.unsafe<OAuthClientRow[]>(` | ||
| 43 | SELECT | ||
| 44 | tenant, | ||
| 45 | owner, | ||
| 46 | slug, | ||
| 47 | "grant", | ||
| 48 | resource, | ||
| 49 | origin_kind, | ||
| 50 | origin_issuer, | ||
| 51 | token_url | ||
| 52 | FROM oauth_client | ||
| 53 | ORDER BY tenant, owner, slug | ||
| 54 | `); | ||
| 55 | |||
| 56 | interface ConnectionCountRow { | ||
| 57 | readonly tenant: string; | ||
| 58 | readonly oauth_client_owner: string; | ||
| 59 | readonly oauth_client: string; | ||
| 60 | readonly count: string | number; | ||
| 61 | } | ||
| 62 | |||
| 63 | const referenceKey = (tenant: string, owner: string, slug: string): string => | ||
| 64 | `${tenant}\u0000${owner}\u0000${slug}`; | ||
| 65 | |||
| 66 | /** One grouped pass over `connection` instead of a COUNT per oauth_client row | ||
| 67 | * (N+1). Returns a Map from (tenant, oauth_client_owner, oauth_client) to the | ||
| 68 | * number of referencing connections. */ | ||
| 69 | const readReferencingConnectionCounts = async ( | ||
| 70 | context: CodeMigrationContext, | ||
| 71 | ): Promise<ReadonlyMap<string, number>> => { | ||
| 72 | const rows = await context.sql.unsafe<ConnectionCountRow[]>(` | ||
| 73 | SELECT tenant, oauth_client_owner, oauth_client, COUNT(*)::int AS count | ||
| 74 | FROM connection | ||
| 75 | WHERE oauth_client IS NOT NULL | ||
| 76 | GROUP BY tenant, oauth_client_owner, oauth_client | ||
| 77 | `); | ||
| 78 | const counts = new Map<string, number>(); | ||
| 79 | for (const row of rows) { | ||
| 80 | counts.set( | ||
| 81 | referenceKey(row.tenant, row.oauth_client_owner, row.oauth_client), | ||
| 82 | Number(row.count ?? 0), | ||
| 83 | ); | ||
| 84 | } | ||
| 85 | return counts; | ||
| 86 | }; | ||
| 87 | |||
| 88 | export const gcDeadDcrOAuthClientsMigration: CodeMigration = { | ||
| 89 | name: "2026-07-02-gc-dead-dcr-oauth-clients", | ||
| 90 | run: async (context) => { | ||
| 91 | const rows = await readOAuthClients(context); | ||
| 92 | const referenceCounts = await readReferencingConnectionCounts(context); | ||
| 93 | |||
| 94 | let deleted = 0; | ||
| 95 | let backfilled = 0; | ||
| 96 | let referencedDcr = 0; | ||
| 97 | let stampedDcr = 0; | ||
| 98 | let stampedManual = 0; | ||
| 99 | |||
| 100 | for (const row of rows) { | ||
| 101 | const isDcr = isDcrClassifiedRow(row); | ||
| 102 | |||
| 103 | // Manual apps are never deleted or issuer-backfilled, but a legacy (null | ||
| 104 | // origin_kind) manual row still gets an explicit `manual` stamp so every | ||
| 105 | // surviving row ends this migration with a concrete classification. | ||
| 106 | if (!isDcr) { | ||
| 107 | if (row.origin_kind == null) { | ||
| 108 | if (!context.dryRun) { | ||
| 109 | await context.sql.unsafe( | ||
| 110 | `UPDATE oauth_client SET origin_kind = 'manual' | ||
| 111 | WHERE tenant = $1 AND owner = $2 AND slug = $3`, | ||
| 112 | [row.tenant, row.owner, row.slug], | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | stampedManual += 1; | ||
| 116 | } | ||
| 117 | continue; | ||
| 118 | } | ||
| 119 | |||
| 120 | const count = referenceCounts.get(referenceKey(row.tenant, row.owner, row.slug)) ?? 0; | ||
| 121 | const decision = classifyOAuthClientGc(row, count); | ||
| 122 | |||
| 123 | if (decision.action === "delete") { | ||
| 124 | if (!context.dryRun) { | ||
| 125 | await context.sql.unsafe( | ||
| 126 | `DELETE FROM oauth_client WHERE tenant = $1 AND owner = $2 AND slug = $3`, | ||
| 127 | [row.tenant, row.owner, row.slug], | ||
| 128 | ); | ||
| 129 | } | ||
| 130 | deleted += 1; | ||
| 131 | continue; | ||
| 132 | } | ||
| 133 | |||
| 134 | referencedDcr += 1; | ||
| 135 | // Surviving DCR row. Stamp a legacy (null origin_kind) survivor as DCR, | ||
| 136 | // and backfill its `origin_issuer` from the registrable origin of | ||
| 137 | // token_url when it has none, so the per-AS reuse lookup keys on it and | ||
| 138 | // mints no new duplicate. Both fold into one UPDATE per row. | ||
| 139 | const setClauses: string[] = []; | ||
| 140 | const setArgs: unknown[] = []; | ||
| 141 | if (row.origin_kind == null) { | ||
| 142 | setClauses.push(`origin_kind = 'dynamic_client_registration'`); | ||
| 143 | stampedDcr += 1; | ||
| 144 | } | ||
| 145 | if (row.origin_issuer == null) { | ||
| 146 | const issuer = row.token_url == null ? null : registrableOriginOfUrl(row.token_url); | ||
| 147 | if (issuer !== null) { | ||
| 148 | setArgs.push(issuer); | ||
| 149 | setClauses.push(`origin_issuer = $${setArgs.length}`); | ||
| 150 | backfilled += 1; | ||
| 151 | } | ||
| 152 | } | ||
| 153 | if (setClauses.length > 0 && !context.dryRun) { | ||
| 154 | const whereBase = setArgs.length; | ||
| 155 | await context.sql.unsafe( | ||
| 156 | `UPDATE oauth_client SET ${setClauses.join(", ")} | ||
| 157 | WHERE tenant = $${whereBase + 1} AND owner = $${whereBase + 2} AND slug = $${whereBase + 3}`, | ||
| 158 | [...setArgs, row.tenant, row.owner, row.slug], | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | const verb = context.dryRun ? "would delete" : "deleted"; | ||
| 164 | const backfillVerb = context.dryRun ? "would backfill" : "backfilled"; | ||
| 165 | const stampVerb = context.dryRun ? "would stamp" : "stamped"; | ||
| 166 | return { | ||
| 167 | summary: | ||
| 168 | `${rows.length} oauth_client row(s): ${verb} ${deleted} orphaned DCR client(s), ` + | ||
| 169 | `${backfillVerb} ${backfilled} of ${referencedDcr} referenced DCR client(s), ` + | ||
| 170 | `${stampVerb} ${stampedDcr + stampedManual} legacy row(s) ` + | ||
| 171 | `(${stampedDcr} dcr, ${stampedManual} manual)`, | ||
| 172 | }; | ||
| 173 | }, | ||
| 174 | }; | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import type { CodeMigration } from "./runner"; | 1 | import type { CodeMigration } from "./runner"; |
| 2 | import { googleOpenApiR2BlobMigration } from "./google-openapi-r2-blobs"; | 2 | import { googleOpenApiR2BlobMigration } from "./google-openapi-r2-blobs"; |
| 3 | import { gcDeadDcrOAuthClientsMigration } from "./gc-dead-dcr-oauth-clients"; | ||
| 3 | 4 | ||
| 4 | export interface CloudCodeMigrationRegistryOptions { | 5 | export interface CloudCodeMigrationRegistryOptions { |
| 5 | readonly r2Bucket?: string; | 6 | readonly r2Bucket?: string; |
| @@ -11,6 +12,9 @@ export const cloudCodeMigrations = ({ · 5 unmodified lines | |||
| 11 | limit, | 12 | limit, |
| 12 | }: CloudCodeMigrationRegistryOptions): readonly CodeMigration[] => [ | 13 | }: CloudCodeMigrationRegistryOptions): readonly CodeMigration[] => [ |
| 13 | ...(r2Bucket ? [googleOpenApiR2BlobMigration({ bucket: r2Bucket, limit })] : []), | 14 | ...(r2Bucket ? [googleOpenApiR2BlobMigration({ bucket: r2Bucket, limit })] : []), |
| 15 | // GC dead DCR oauth_client rows + backfill surviving rows' origin_issuer | ||
| 16 | // (issue #1120, Part C). No R2/bucket dependency, so always registered. | ||
| 17 | gcDeadDcrOAuthClientsMigration, | ||
| 14 | ]; | 18 | ]; |
| 15 | 19 | ||
| 16 | export { runCodeMigrations } from "./runner"; | 20 | export { runCodeMigrations } from "./runner"; |
| @@ -85,6 +85,7 @@ export const oauth_client = pgTable( · 84 unmodified lines | |||
| 85 | resource: text("resource"), | 85 | resource: text("resource"), |
| 86 | origin_kind: text("origin_kind"), | 86 | origin_kind: text("origin_kind"), |
| 87 | origin_integration: text("origin_integration"), | 87 | origin_integration: text("origin_integration"), |
| 88 | origin_issuer: text("origin_issuer"), | ||
| 88 | created_at: timestamp("created_at").notNull(), | 89 | created_at: timestamp("created_at").notNull(), |
| 89 | row_id: varchar("row_id", { length: 255 }) | 90 | row_id: varchar("row_id", { length: 255 }) |
| 90 | .primaryKey() | 91 | .primaryKey() |
| @@ -0,0 +1,207 @@ | |||
| 1 | /* oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- test fake throws on an unexpected query to catch wiring drift */ | ||
| 2 | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import { Effect } from "effect"; | ||
| 5 | |||
| 6 | import { gcDeadDcrOAuthClientsMigration } from "../../scripts/code-migrations/gc-dead-dcr-oauth-clients"; | ||
| 7 | import type { CodeMigrationContext, CodeMigrationSql } from "../../scripts/code-migrations/runner"; | ||
| 8 | |||
| 9 | // Cloud counterpart of the local libSQL GC migration test. A small in-memory | ||
| 10 | // fake models the SQL shapes the migration issues (the oauth_client SELECT, the | ||
| 11 | // single GROUPED connection-count query, and the DELETE/UPDATE mutations) so the | ||
| 12 | // same shared decision matrix is proven end-to-end over the Postgres wiring. | ||
| 13 | |||
| 14 | interface OAuthClientRow { | ||
| 15 | readonly tenant: string; | ||
| 16 | readonly owner: string; | ||
| 17 | readonly slug: string; | ||
| 18 | readonly grant: string | null; | ||
| 19 | readonly resource: string | null; | ||
| 20 | readonly origin_kind: string | null; | ||
| 21 | readonly origin_issuer: string | null; | ||
| 22 | readonly token_url: string | null; | ||
| 23 | } | ||
| 24 | |||
| 25 | const makeFake = (input: { | ||
| 26 | rows: OAuthClientRow[]; | ||
| 27 | /** connection count keyed by `${tenant}|${owner}|${slug}`. */ | ||
| 28 | readonly references: Record<string, number>; | ||
| 29 | }) => { | ||
| 30 | const deletes: string[] = []; | ||
| 31 | const updates: { slug: string; issuer: string }[] = []; | ||
| 32 | const stamps: { slug: string; kind: string }[] = []; | ||
| 33 | |||
| 34 | const sql: CodeMigrationSql = { | ||
| 35 | unsafe: (query: string, params?: readonly unknown[]) => { | ||
| 36 | const q = query.trim(); | ||
| 37 | if (q.startsWith("SELECT") && q.includes("FROM oauth_client")) { | ||
| 38 | return Promise.resolve(input.rows as never); | ||
| 39 | } | ||
| 40 | if (q.startsWith("SELECT") && q.includes("FROM connection")) { | ||
| 41 | // One grouped pass: emit a row per referenced (tenant, owner, slug). | ||
| 42 | const grouped = Object.entries(input.references).map(([composite, count]) => { | ||
| 43 | const [tenant, owner, slug] = composite.split("|"); | ||
| 44 | return { tenant, oauth_client_owner: owner, oauth_client: slug, count }; | ||
| 45 | }); | ||
| 46 | return Promise.resolve(grouped as never); | ||
| 47 | } | ||
| 48 | if (q.startsWith("DELETE FROM oauth_client")) { | ||
| 49 | const [tenant, owner, slug] = params as [string, string, string]; | ||
| 50 | input.rows = input.rows.filter( | ||
| 51 | (r) => !(r.tenant === tenant && r.owner === owner && r.slug === slug), | ||
| 52 | ); | ||
| 53 | deletes.push(slug); | ||
| 54 | return Promise.resolve([] as never); | ||
| 55 | } | ||
| 56 | if (q.startsWith("UPDATE oauth_client")) { | ||
| 57 | // The slug is always the last positional param. The DCR-survivor UPDATE | ||
| 58 | // may set origin_issuer (an `origin_issuer = $N` clause with the issuer | ||
| 59 | // as its first param) and/or stamp origin_kind (a literal in the SQL). | ||
| 60 | const args = (params ?? []) as readonly string[]; | ||
| 61 | const slug = args[args.length - 1]; | ||
| 62 | if (q.includes("origin_kind = 'manual'")) { | ||
| 63 | stamps.push({ slug, kind: "manual" }); | ||
| 64 | } | ||
| 65 | if (q.includes("origin_kind = 'dynamic_client_registration'")) { | ||
| 66 | stamps.push({ slug, kind: "dynamic_client_registration" }); | ||
| 67 | } | ||
| 68 | if (q.includes("origin_issuer =")) { | ||
| 69 | updates.push({ slug, issuer: args[0] }); | ||
| 70 | } | ||
| 71 | return Promise.resolve([] as never); | ||
| 72 | } | ||
| 73 | throw new Error(`unexpected query: ${q}`); | ||
| 74 | }, | ||
| 75 | }; | ||
| 76 | |||
| 77 | return { sql, deletes, updates, stamps, rowsRef: () => input.rows }; | ||
| 78 | }; | ||
| 79 | |||
| 80 | const row = (over: Partial<OAuthClientRow> & { readonly slug: string }): OAuthClientRow => ({ | ||
| 81 | tenant: "t1", | ||
| 82 | owner: "org", | ||
| 83 | slug: over.slug, | ||
| 84 | grant: over.grant ?? "authorization_code", | ||
| 85 | resource: over.resource ?? null, | ||
| 86 | origin_kind: over.origin_kind ?? null, | ||
| 87 | origin_issuer: over.origin_issuer ?? null, | ||
| 88 | token_url: over.token_url ?? "https://oauth.cloudflare.com/token", | ||
| 89 | }); | ||
| 90 | |||
| 91 | const runMigration = ( | ||
| 92 | fake: ReturnType<typeof makeFake>, | ||
| 93 | dryRun: boolean, | ||
| 94 | ): Promise<{ readonly summary: string }> => { | ||
| 95 | const context: CodeMigrationContext = { sql: fake.sql, dryRun, log: () => {} }; | ||
| 96 | return gcDeadDcrOAuthClientsMigration.run(context); | ||
| 97 | }; | ||
| 98 | |||
| 99 | describe("gcDeadDcrOAuthClientsMigration", () => { | ||
| 100 | it.effect("deletes orphaned DCR, keeps manual + referenced, backfills survivors", () => | ||
| 101 | Effect.promise(async () => { | ||
| 102 | const fake = makeFake({ | ||
| 103 | rows: [ | ||
| 104 | row({ | ||
| 105 | slug: "dcr-cloudflare-com", | ||
| 106 | resource: "https://cf.example/mcp", | ||
| 107 | origin_kind: "dynamic_client_registration", | ||
| 108 | origin_issuer: "https://cloudflare.com", | ||
| 109 | }), | ||
| 110 | row({ slug: "cloudflare-mcp-2", resource: "https://cf.example/mcp" }), | ||
| 111 | row({ slug: "cloudflare-mcp", resource: "https://cf.example/mcp" }), | ||
| 112 | row({ | ||
| 113 | slug: "my-github-app", | ||
| 114 | token_url: "https://github.com/login/oauth/access_token", | ||
| 115 | origin_kind: "manual", | ||
| 116 | }), | ||
| 117 | ], | ||
| 118 | references: { "t1|org|cloudflare-mcp": 1 }, | ||
| 119 | }); | ||
| 120 | |||
| 121 | const result = await runMigration(fake, false); | ||
| 122 | |||
| 123 | expect(fake.deletes.sort()).toEqual(["cloudflare-mcp-2", "dcr-cloudflare-com"]); | ||
| 124 | expect(fake.updates).toEqual([{ slug: "cloudflare-mcp", issuer: "https://cloudflare.com" }]); | ||
| 125 | // The surviving legacy DCR row gets an explicit DCR stamp; the already | ||
| 126 | // explicitly-stamped manual app is never restamped. | ||
| 127 | expect(fake.stamps).toEqual([ | ||
| 128 | { slug: "cloudflare-mcp", kind: "dynamic_client_registration" }, | ||
| 129 | ]); | ||
| 130 | expect( | ||
| 131 | fake | ||
| 132 | .rowsRef() | ||
| 133 | .map((r) => r.slug) | ||
| 134 | .sort(), | ||
| 135 | ).toEqual(["cloudflare-mcp", "my-github-app"]); | ||
| 136 | expect(result.summary).toContain("deleted 2 orphaned DCR client(s)"); | ||
| 137 | expect(result.summary).toContain("backfilled 1 of 1 referenced DCR client(s)"); | ||
| 138 | expect(result.summary).toContain("stamped 1 legacy row(s) (1 dcr, 0 manual)"); | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | it.effect("stamps a legacy null-origin manual survivor as manual with no issuer", () => | ||
| 143 | Effect.promise(async () => { | ||
| 144 | const fake = makeFake({ | ||
| 145 | rows: [ | ||
| 146 | // Legacy manual row: null origin_kind, auth-code, NO resource → manual. | ||
| 147 | row({ | ||
| 148 | slug: "legacy-manual", | ||
| 149 | token_url: "https://github.com/login/oauth/access_token", | ||
| 150 | resource: null, | ||
| 151 | }), | ||
| 152 | ], | ||
| 153 | references: {}, | ||
| 154 | }); | ||
| 155 | |||
| 156 | const result = await runMigration(fake, false); | ||
| 157 | |||
| 158 | expect(fake.deletes).toEqual([]); | ||
| 159 | expect(fake.updates).toEqual([]); | ||
| 160 | expect(fake.stamps).toEqual([{ slug: "legacy-manual", kind: "manual" }]); | ||
| 161 | expect(fake.rowsRef().map((r) => r.slug)).toEqual(["legacy-manual"]); | ||
| 162 | expect(result.summary).toContain("stamped 1 legacy row(s) (0 dcr, 1 manual)"); | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | |||
| 166 | it.effect("dry run mutates nothing but reports the plan", () => | ||
| 167 | Effect.promise(async () => { | ||
| 168 | const fake = makeFake({ | ||
| 169 | rows: [ | ||
| 170 | row({ slug: "cloudflare-mcp-2", resource: "https://cf.example/mcp" }), | ||
| 171 | row({ slug: "cloudflare-mcp", resource: "https://cf.example/mcp" }), | ||
| 172 | ], | ||
| 173 | references: { "t1|org|cloudflare-mcp": 1 }, | ||
| 174 | }); | ||
| 175 | |||
| 176 | const result = await runMigration(fake, true); | ||
| 177 | |||
| 178 | expect(fake.deletes).toEqual([]); | ||
| 179 | expect(fake.updates).toEqual([]); | ||
| 180 | expect(fake.stamps).toEqual([]); | ||
| 181 | expect(fake.rowsRef()).toHaveLength(2); | ||
| 182 | expect(result.summary).toContain("would delete 1 orphaned DCR client(s)"); | ||
| 183 | expect(result.summary).toContain("would backfill 1 of 1 referenced DCR client(s)"); | ||
| 184 | // The referenced legacy DCR survivor WOULD be stamped, counted but not | ||
| 185 | // written. | ||
| 186 | expect(result.summary).toContain("would stamp 1 legacy row(s) (1 dcr, 0 manual)"); | ||
| 187 | }), | ||
| 188 | ); | ||
| 189 | |||
| 190 | it.effect("never deletes a manual app even when orphaned and MCP-shaped", () => | ||
| 191 | Effect.promise(async () => { | ||
| 192 | const fake = makeFake({ | ||
| 193 | rows: [ | ||
| 194 | row({ | ||
| 195 | slug: "cloudflare-mcp", | ||
| 196 | resource: "https://cf.example/mcp", | ||
| 197 | origin_kind: "manual", | ||
| 198 | }), | ||
| 199 | ], | ||
| 200 | references: {}, | ||
| 201 | }); | ||
| 202 | await runMigration(fake, false); | ||
| 203 | expect(fake.deletes).toEqual([]); | ||
| 204 | expect(fake.rowsRef().map((r) => r.slug)).toEqual(["cloudflare-mcp"]); | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | }); | ||
| @@ -0,0 +1,185 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Pre-execution balance gate — blocks new executions once an organization has | ||
| 3 | // used up its plan's included executions. | ||
| 4 | // | ||
| 5 | // Usage is tracked to Autumn after every execution (execution-usage.ts), but | ||
| 6 | // nothing ever CHECKED the balance before running, so a free-tier org could run | ||
| 7 | // unbounded executions past its quota. This gate consults | ||
| 8 | // `AutumnService.checkExecutionBalance` before `execute` / `executeWithPause`. | ||
| 9 | // `resume` is never gated: a paused execution already consumed its quota slot | ||
| 10 | // when it started, and blocking resume would strand approved work forever. | ||
| 11 | // | ||
| 12 | // FAIL OPEN is a hard requirement: any Autumn error, timeout, or missing | ||
| 13 | // customer/feature allows the execution (logged + Sentry, mirroring | ||
| 14 | // `trackExecution`'s reporting). Autumn being down must never block | ||
| 15 | // executions or add meaningful latency, so the check is bounded by a short | ||
| 16 | // timeout and its outcome is cached per organization. | ||
| 17 | // | ||
| 18 | // Error surfacing: the gate fails internally with the typed | ||
| 19 | // `ExecutionLimitReachedError`, and the engine seam folds it into the | ||
| 20 | // descriptive `ExecuteResult.error` channel. That is the codebase's mechanism | ||
| 21 | // for getting a domain failure's message to the MCP client verbatim — engine | ||
| 22 | // error-channel failures are deliberately rendered opaque by the MCP host | ||
| 23 | // ("Internal tool error [correlation id]"), exactly like the runtimes fold | ||
| 24 | // `CodeCompilationError` / `SandboxRuntimeError` into `ExecuteResult.error`. | ||
| 25 | // --------------------------------------------------------------------------- | ||
| 26 | |||
| 27 | import * as Sentry from "@sentry/cloudflare"; | ||
| 28 | import { Data, Effect } from "effect"; | ||
| 29 | import type * as Cause from "effect/Cause"; | ||
| 30 | |||
| 31 | import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; | ||
| 32 | |||
| 33 | import { EXECUTION_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; | ||
| 34 | |||
| 35 | // The engine's completed-result payload (`ExecuteResult` in codemode-core), | ||
| 36 | // derived from the public execution types so this app package doesn't need a | ||
| 37 | // direct dependency on the kernel package that declares it. | ||
| 38 | type EngineExecuteResult = Extract<ExecutionResult, { status: "completed" }>["result"]; | ||
| 39 | |||
| 40 | // One check per org per minute is fresh enough for a monthly quota; both | ||
| 41 | // allowed AND blocked outcomes are cached (errors never are). | ||
| 42 | const BALANCE_CACHE_TTL_MS = 60_000; | ||
| 43 | // Autumn slower than this => fail open rather than stall a user-facing | ||
| 44 | // execution behind the billing provider. | ||
| 45 | const BALANCE_CHECK_TIMEOUT_MS = 2_000; | ||
| 46 | // Sweep guard so a long-lived worker isolate serving many orgs can't grow the | ||
| 47 | // cache map unbounded. | ||
| 48 | const BALANCE_CACHE_MAX_ENTRIES = 10_000; | ||
| 49 | |||
| 50 | export { EXECUTION_LIMIT_BLOCKED_MESSAGE }; | ||
| 51 | |||
| 52 | export class ExecutionLimitReachedError extends Data.TaggedError("ExecutionLimitReachedError")<{ | ||
| 53 | readonly organizationId: string; | ||
| 54 | readonly message: string; | ||
| 55 | }> {} | ||
| 56 | |||
| 57 | /** Internal sentinel for a balance check that exceeded its time budget. */ | ||
| 58 | class GateCheckTimeoutError extends Data.TaggedError("GateCheckTimeoutError")<{ | ||
| 59 | readonly timeoutMs: number; | ||
| 60 | }> {} | ||
| 61 | |||
| 62 | // --------------------------------------------------------------------------- | ||
| 63 | // Shared gate seam — used by this gate and the rate-limit backstop. | ||
| 64 | // --------------------------------------------------------------------------- | ||
| 65 | |||
| 66 | export type GateDecision = | ||
| 67 | | { readonly blocked: false } | ||
| 68 | | { readonly blocked: true; readonly error: { readonly message: string } }; | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Wrap an engine so `decide` runs before `execute` / `executeWithPause`. A | ||
| 72 | * blocked decision short-circuits to a descriptive `ExecuteResult.error` | ||
| 73 | * (which `formatExecuteResult` renders as a clean `isError` MCP tool result) | ||
| 74 | * WITHOUT invoking the inner engine — so a blocked execution is neither run | ||
| 75 | * nor usage-tracked. `resume` and all read-only members pass through | ||
| 76 | * untouched: paused executions must always be able to complete. | ||
| 77 | */ | ||
| 78 | export const withPreExecutionGate = <E extends Cause.YieldableError>( | ||
| 79 | engine: ExecutionEngine<E>, | ||
| 80 | decide: Effect.Effect<GateDecision>, | ||
| 81 | ): ExecutionEngine<E> => ({ | ||
| 82 | execute: (code, options) => | ||
| 83 | Effect.flatMap( | ||
| 84 | decide, | ||
| 85 | (decision): Effect.Effect<EngineExecuteResult, E> => | ||
| 86 | decision.blocked | ||
| 87 | ? Effect.succeed({ result: null, error: decision.error.message }) | ||
| 88 | : engine.execute(code, options), | ||
| 89 | ), | ||
| 90 | executeWithPause: (code, options) => | ||
| 91 | Effect.flatMap( | ||
| 92 | decide, | ||
| 93 | (decision): Effect.Effect<ExecutionResult, E> => | ||
| 94 | decision.blocked | ||
| 95 | ? Effect.succeed({ | ||
| 96 | status: "completed", | ||
| 97 | result: { result: null, error: decision.error.message }, | ||
| 98 | }) | ||
| 99 | : engine.executeWithPause(code, options), | ||
| 100 | ), | ||
| 101 | // resume is never gated: paused executions must be able to complete. | ||
| 102 | resume: (executionId, response) => engine.resume(executionId, response), | ||
| 103 | getPausedExecution: (executionId) => engine.getPausedExecution(executionId), | ||
| 104 | pausedExecutionCount: () => engine.pausedExecutionCount(), | ||
| 105 | hasPausedExecutions: () => engine.hasPausedExecutions(), | ||
| 106 | getDescription: engine.getDescription, | ||
| 107 | }); | ||
| 108 | |||
| 109 | // --------------------------------------------------------------------------- | ||
| 110 | // Balance gate factory | ||
| 111 | // --------------------------------------------------------------------------- | ||
| 112 | |||
| 113 | export type ExecutionBalanceCheck = ( | ||
| 114 | organizationId: string, | ||
| 115 | ) => Effect.Effect<{ readonly allowed: boolean }, unknown>; | ||
| 116 | |||
| 117 | /** | ||
| 118 | * Build a balance gate around `checkBalance` (in production: | ||
| 119 | * `AutumnService.checkExecutionBalance`). One gate instance holds one | ||
| 120 | * per-organization outcome cache; the metered decorator layer creates a single | ||
| 121 | * instance so all engines it decorates share it. | ||
| 122 | */ | ||
| 123 | export const makeExecutionLimitGate = (checkBalance: ExecutionBalanceCheck) => { | ||
| 124 | const timeoutMs = BALANCE_CHECK_TIMEOUT_MS; | ||
| 125 | const cache = new Map<string, { readonly allowed: boolean; readonly expiresAtMs: number }>(); | ||
| 126 | |||
| 127 | const writeCache = (organizationId: string, allowed: boolean, nowMs: number): void => { | ||
| 128 | if (cache.size >= BALANCE_CACHE_MAX_ENTRIES) { | ||
| 129 | for (const [key, entry] of cache) { | ||
| 130 | if (entry.expiresAtMs <= nowMs) cache.delete(key); | ||
| 131 | } | ||
| 132 | // Still saturated after dropping expired entries: reset rather than grow. | ||
| 133 | if (cache.size >= BALANCE_CACHE_MAX_ENTRIES) cache.clear(); | ||
| 134 | } | ||
| 135 | cache.set(organizationId, { allowed, expiresAtMs: nowMs + BALANCE_CACHE_TTL_MS }); | ||
| 136 | }; | ||
| 137 | |||
| 138 | const toDecision = (organizationId: string, allowed: boolean): GateDecision => | ||
| 139 | allowed | ||
| 140 | ? { blocked: false } | ||
| 141 | : { | ||
| 142 | blocked: true, | ||
| 143 | error: new ExecutionLimitReachedError({ | ||
| 144 | organizationId, | ||
| 145 | message: EXECUTION_LIMIT_BLOCKED_MESSAGE, | ||
| 146 | }), | ||
| 147 | }; | ||
| 148 | |||
| 149 | const decide = (organizationId: string): Effect.Effect<GateDecision> => | ||
| 150 | Effect.suspend(() => { | ||
| 151 | const nowMs = Date.now(); | ||
| 152 | const cached = cache.get(organizationId); | ||
| 153 | if (cached && cached.expiresAtMs > nowMs) { | ||
| 154 | return Effect.succeed(toDecision(organizationId, cached.allowed)); | ||
| 155 | } | ||
| 156 | return checkBalance(organizationId).pipe( | ||
| 157 | Effect.timeoutOrElse({ | ||
| 158 | duration: `${timeoutMs} millis`, | ||
| 159 | orElse: () => Effect.fail(new GateCheckTimeoutError({ timeoutMs })), | ||
| 160 | }), | ||
| 161 | Effect.map(({ allowed }) => { | ||
| 162 | writeCache(organizationId, allowed, nowMs); | ||
| 163 | return toDecision(organizationId, allowed); | ||
| 164 | }), | ||
| 165 | // FAIL OPEN: Autumn errors, timeouts, and missing customers/features | ||
| 166 | // must never block executions. Reported like `trackExecution` so a | ||
| 167 | // billing outage still pages; the error outcome is never cached. | ||
| 168 | Effect.catch((error: unknown) => | ||
| 169 | Effect.sync((): GateDecision => { | ||
| 170 | console.warn("[billing] execution balance check failed open:", error); | ||
| 171 | Sentry.captureException(error); | ||
| 172 | return { blocked: false }; | ||
| 173 | }), | ||
| 174 | ), | ||
| 175 | ); | ||
| 176 | }); | ||
| 177 | |||
| 178 | return { | ||
| 179 | /** Wrap an engine so the balance gate runs before each new execution. */ | ||
| 180 | decorate: <E extends Cause.YieldableError>( | ||
| 181 | organizationId: string, | ||
| 182 | engine: ExecutionEngine<E>, | ||
| 183 | ): ExecutionEngine<E> => withPreExecutionGate(engine, decide(organizationId)), | ||
| 184 | }; | ||
| 185 | }; | ||
| @@ -0,0 +1,11 @@ | |||
| 1 | // The user-facing messages a blocked execution surfaces to the MCP client, kept | ||
| 2 | // in their own Cloudflare-free module so the e2e suite can import them as | ||
| 3 | // ground truth without pulling the guards' `cloudflare:workers` dependencies | ||
| 4 | // into its (worker-types-free) typecheck. The guard modules re-export these, so | ||
| 5 | // production call sites are unchanged. | ||
| 6 | |||
| 7 | export const EXECUTION_LIMIT_BLOCKED_MESSAGE = | ||
| 8 | "Execution limit reached: your plan's included executions for this billing period are used up. Upgrade your plan or wait for the reset to continue."; | ||
| 9 | |||
| 10 | export const RATE_LIMIT_BLOCKED_MESSAGE = | ||
| 11 | "Rate limit exceeded: too many executions this hour. This is an abuse backstop — contact support if you hit this legitimately."; | ||
| @@ -0,0 +1,220 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Per-org execution rate limit — an abuse backstop independent of billing. | ||
| 3 | // | ||
| 4 | // The balance gate (execution-gate.ts) depends on Autumn and fails open, so a | ||
| 5 | // billing outage plus runaway automation could still run unbounded executions. | ||
| 6 | // This limiter counts `execute` calls per organization in a fixed hourly | ||
| 7 | // window, backed by a minimal counter Durable Object (cross-session state: | ||
| 8 | // each MCP session lives in its own DO instance, so an in-memory counter | ||
| 9 | // would be per-session and trivially bypassed by opening more sessions). | ||
| 10 | // | ||
| 11 | // Like the balance gate it FAILS OPEN: an unreachable counter DO, a missing | ||
| 12 | // binding, or a slow call allows the execution (warn + Sentry). The backstop | ||
| 13 | // must never take executions down with it. | ||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | |||
| 16 | import { DurableObject, env } from "cloudflare:workers"; | ||
| 17 | import * as Sentry from "@sentry/cloudflare"; | ||
| 18 | import { Data, Effect } from "effect"; | ||
| 19 | import type * as Cause from "effect/Cause"; | ||
| 20 | |||
| 21 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 22 | |||
| 23 | import { withPreExecutionGate, type GateDecision } from "./execution-gate"; | ||
| 24 | import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; | ||
| 25 | |||
| 26 | // Fixed window: all executions in the same clock hour share one counter. | ||
| 27 | export const RATE_LIMIT_WINDOW_MS = 3_600_000; | ||
| 28 | // Calibration: the heaviest legitimate org runs ~1.1k executions per MONTH, | ||
| 29 | // so 1000 per HOUR is far above any human-driven usage and only trips on | ||
| 30 | // runaway automation (the incident this backstops: ~18k in 30 days would | ||
| 31 | // still pass, which is fine — that class of overrun is the balance gate's | ||
| 32 | // job; this catches tight loops). | ||
| 33 | export const EXECUTIONS_PER_ORG_PER_HOUR = 1000; | ||
| 34 | // Counter DO slower than this => fail open rather than stall executions. | ||
| 35 | const RATE_LIMIT_CHECK_TIMEOUT_MS = 2_000; | ||
| 36 | // The DO purges its storage this long after the last increment, so idle orgs | ||
| 37 | // cost nothing. Two windows: long enough that an active window never purges. | ||
| 38 | const COUNTER_PURGE_AFTER_MS = 2 * RATE_LIMIT_WINDOW_MS; | ||
| 39 | |||
| 40 | export { RATE_LIMIT_BLOCKED_MESSAGE }; | ||
| 41 | |||
| 42 | export class ExecutionRateLimitExceededError extends Data.TaggedError( | ||
| 43 | "ExecutionRateLimitExceededError", | ||
| 44 | )<{ | ||
| 45 | readonly organizationId: string; | ||
| 46 | readonly message: string; | ||
| 47 | }> {} | ||
| 48 | |||
| 49 | /** Internal sentinel for a counter call that exceeded its time budget. */ | ||
| 50 | class RateLimitCheckTimeoutError extends Data.TaggedError("RateLimitCheckTimeoutError")<{ | ||
| 51 | readonly timeoutMs: number; | ||
| 52 | }> {} | ||
| 53 | |||
| 54 | // --------------------------------------------------------------------------- | ||
| 55 | // Counter Durable Object — one instance per organization (idFromName(orgId)). | ||
| 56 | // Stores a single { windowId, count } record: an increment in a new window | ||
| 57 | // resets the count, so old windows never accumulate. An alarm purges storage | ||
| 58 | // after inactivity. | ||
| 59 | // --------------------------------------------------------------------------- | ||
| 60 | |||
| 61 | const WINDOW_RECORD_KEY = "window"; | ||
| 62 | |||
| 63 | type WindowRecord = { | ||
| 64 | readonly windowId: number; | ||
| 65 | readonly count: number; | ||
| 66 | }; | ||
| 67 | |||
| 68 | export class ExecutionRateLimiterDO extends DurableObject { | ||
| 69 | private readonly counterStorage: DurableObjectState["storage"]; | ||
| 70 | |||
| 71 | constructor(ctx: DurableObjectState, doEnv: Env) { | ||
| 72 | super(ctx, doEnv); | ||
| 73 | // Kept on an own field (not just inherited `this.ctx`) so tests can run | ||
| 74 | // the class against a fake storage under the `cloudflare:workers` stub. | ||
| 75 | this.counterStorage = ctx.storage; | ||
| 76 | } | ||
| 77 | |||
| 78 | /** Add one execution to `windowId`'s counter and return the new count. */ | ||
| 79 | async increment(windowId: number): Promise<number> { | ||
| 80 | const stored = await this.counterStorage.get<WindowRecord>(WINDOW_RECORD_KEY); | ||
| 81 | const count = stored && stored.windowId === windowId ? stored.count + 1 : 1; | ||
| 82 | await this.counterStorage.put(WINDOW_RECORD_KEY, { windowId, count }); | ||
| 83 | await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS); | ||
| 84 | return count; | ||
| 85 | } | ||
| 86 | |||
| 87 | async alarm(): Promise<void> { | ||
| 88 | await this.counterStorage.deleteAll(); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | // --------------------------------------------------------------------------- | ||
| 93 | // Client | ||
| 94 | // --------------------------------------------------------------------------- | ||
| 95 | |||
| 96 | /** Count one execution for (organizationId, windowId); returns the new count. */ | ||
| 97 | export type RateLimitIncrement = ( | ||
| 98 | organizationId: string, | ||
| 99 | windowId: number, | ||
| 100 | ) => Effect.Effect<number, unknown>; | ||
| 101 | |||
| 102 | export type ExecutionRateLimiter = { | ||
| 103 | readonly decorate: <E extends Cause.YieldableError>( | ||
| 104 | organizationId: string, | ||
| 105 | engine: ExecutionEngine<E>, | ||
| 106 | ) => ExecutionEngine<E>; | ||
| 107 | }; | ||
| 108 | |||
| 109 | /** | ||
| 110 | * Build a rate limiter around an increment function (in production: the | ||
| 111 | * counter DO). `options.limit` is the per-org hourly cap (production sets it | ||
| 112 | * from the env override in `makeCloudExecutionRateLimiter`); the rest tune the | ||
| 113 | * window and time budget. | ||
| 114 | */ | ||
| 115 | export const makeExecutionRateLimiter = ( | ||
| 116 | increment: RateLimitIncrement, | ||
| 117 | options?: { | ||
| 118 | readonly limit?: number; | ||
| 119 | readonly windowMs?: number; | ||
| 120 | readonly timeoutMs?: number; | ||
| 121 | readonly now?: () => number; | ||
| 122 | }, | ||
| 123 | ): ExecutionRateLimiter => { | ||
| 124 | const limit = options?.limit ?? EXECUTIONS_PER_ORG_PER_HOUR; | ||
| 125 | const windowMs = options?.windowMs ?? RATE_LIMIT_WINDOW_MS; | ||
| 126 | const timeoutMs = options?.timeoutMs ?? RATE_LIMIT_CHECK_TIMEOUT_MS; | ||
| 127 | const now = options?.now ?? Date.now; | ||
| 128 | |||
| 129 | const decide = (organizationId: string): Effect.Effect<GateDecision> => | ||
| 130 | Effect.suspend(() => { | ||
| 131 | const windowId = Math.floor(now() / windowMs); | ||
| 132 | return increment(organizationId, windowId).pipe( | ||
| 133 | Effect.timeoutOrElse({ | ||
| 134 | duration: `${timeoutMs} millis`, | ||
| 135 | orElse: () => Effect.fail(new RateLimitCheckTimeoutError({ timeoutMs })), | ||
| 136 | }), | ||
| 137 | Effect.map( | ||
| 138 | (count): GateDecision => | ||
| 139 | count > limit | ||
| 140 | ? { | ||
| 141 | blocked: true, | ||
| 142 | error: new ExecutionRateLimitExceededError({ | ||
| 143 | organizationId, | ||
| 144 | message: RATE_LIMIT_BLOCKED_MESSAGE, | ||
| 145 | }), | ||
| 146 | } | ||
| 147 | : { blocked: false }, | ||
| 148 | ), | ||
| 149 | // FAIL OPEN: the backstop must never block executions because its | ||
| 150 | // counter is unreachable or slow. | ||
| 151 | Effect.catch((error: unknown) => | ||
| 152 | Effect.sync((): GateDecision => { | ||
| 153 | console.warn("[rate-limit] execution rate limit check failed open:", error); | ||
| 154 | Sentry.captureException(error); | ||
| 155 | return { blocked: false }; | ||
| 156 | }), | ||
| 157 | ), | ||
| 158 | ); | ||
| 159 | }); | ||
| 160 | |||
| 161 | return { | ||
| 162 | decorate: (organizationId, engine) => withPreExecutionGate(engine, decide(organizationId)), | ||
| 163 | }; | ||
| 164 | }; | ||
| 165 | |||
| 166 | // --------------------------------------------------------------------------- | ||
| 167 | // Cloud wiring — reads the EXECUTION_RATE_LIMITER binding from the worker env. | ||
| 168 | // --------------------------------------------------------------------------- | ||
| 169 | |||
| 170 | // The DO stub's RPC surface. The binding is declared untyped in | ||
| 171 | // env-augment.d.ts (matching the BLOBS precedent), so the call site narrows it | ||
| 172 | // to the one method the class exposes. | ||
| 173 | type ExecutionRateLimiterStub = { | ||
| 174 | readonly increment: (windowId: number) => Promise<number>; | ||
| 175 | }; | ||
| 176 | |||
| 177 | type RateLimiterNamespace = { | ||
| 178 | readonly idFromName: (name: string) => DurableObjectId; | ||
| 179 | readonly get: (id: DurableObjectId) => unknown; | ||
| 180 | }; | ||
| 181 | |||
| 182 | /** | ||
| 183 | * Production rate limiter backed by the `EXECUTION_RATE_LIMITER` counter DO. | ||
| 184 | * When the binding is absent (unit-test workers, older local setups) the | ||
| 185 | * limiter is disabled: every check passes, logged once at construction. | ||
| 186 | */ | ||
| 187 | export const makeCloudExecutionRateLimiter = (): ExecutionRateLimiter => { | ||
| 188 | const limit = resolveRateLimit(); | ||
| 189 | const namespace = (env as { EXECUTION_RATE_LIMITER?: RateLimiterNamespace }) | ||
| 190 | .EXECUTION_RATE_LIMITER; | ||
| 191 | if (!namespace) { | ||
| 192 | console.warn( | ||
| 193 | "[rate-limit] EXECUTION_RATE_LIMITER binding missing; execution rate limiting disabled", | ||
| 194 | ); | ||
| 195 | return makeExecutionRateLimiter(() => Effect.succeed(0)); | ||
| 196 | } | ||
| 197 | return makeExecutionRateLimiter( | ||
| 198 | (organizationId, windowId) => | ||
| 199 | Effect.tryPromise(() => { | ||
| 200 | const stub = namespace.get( | ||
| 201 | namespace.idFromName(organizationId), | ||
| 202 | ) as ExecutionRateLimiterStub; | ||
| 203 | return stub.increment(windowId); | ||
| 204 | }), | ||
| 205 | { limit }, | ||
| 206 | ); | ||
| 207 | }; | ||
| 208 | |||
| 209 | /** | ||
| 210 | * The per-org hourly cap: the `EXECUTION_RATE_LIMIT_PER_HOUR` env override | ||
| 211 | * (parsed as a positive integer) or `EXECUTIONS_PER_ORG_PER_HOUR` when it's | ||
| 212 | * unset or unparseable. The override exists so e2e can drive the backstop with | ||
| 213 | * a small number of real executions; production leaves the var unset. | ||
| 214 | */ | ||
| 215 | const resolveRateLimit = (): number => { | ||
| 216 | const raw = (env as { EXECUTION_RATE_LIMIT_PER_HOUR?: string }).EXECUTION_RATE_LIMIT_PER_HOUR; | ||
| 217 | if (raw === undefined) return EXECUTIONS_PER_ORG_PER_HOUR; | ||
| 218 | const parsed = Number.parseInt(raw, 10); | ||
| 219 | return Number.isInteger(parsed) && parsed > 0 ? parsed : EXECUTIONS_PER_ORG_PER_HOUR; | ||
| 220 | }; | ||
| @@ -28,19 +28,40 @@ import { · 27 unmodified lines | |||
| 28 | import { AutumnService } from "../extensions/billing/service"; | 28 | import { AutumnService } from "../extensions/billing/service"; |
| 29 | import type { DbService } from "../db/db"; | 29 | import type { DbService } from "../db/db"; |
| 30 | import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; | 30 | import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; |
| 31 | import { makeExecutionLimitGate } from "./execution-gate"; | ||
| 32 | import { makeCloudExecutionRateLimiter } from "./execution-rate-limit"; | ||
| 31 | import { withExecutionUsageTracking } from "./execution-usage"; | 33 | import { withExecutionUsageTracking } from "./execution-usage"; |
| 32 | 34 | ||
| 33 | // Usage-metering decorator bound to the billing service. `trackExecution` is | 35 | // Usage-metering decorator bound to the billing service, plus the two |
| 34 | // fire-and-forget (`Effect.runFork`) so the billing call can't stall a | 36 | // pre-execution guards this layer owns, ordered cheapest first: |
| 35 | // user-facing execution. | 37 | // 1. rate-limit backstop (counter DO, independent of billing) |
| 38 | // 2. execution balance gate (Autumn check, cached 60s, fails open) | ||
| 39 | // 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing | ||
| 40 | // call can't stall a user-facing execution. | ||
| 41 | // The guards wrap OUTSIDE the tracker so a blocked execution is neither run | ||
| 42 | // nor tracked. One gate/limiter instance per layer build: the balance cache is | ||
| 43 | // shared by every engine this decorator produces (in the MCP session DO that's | ||
| 44 | // the session's engines; on the HTTP plane the boot layer builds it once). | ||
| 36 | export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> = | 45 | export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> = |
| 37 | Layer.effect(EngineDecorator)( | 46 | Layer.effect(EngineDecorator)( |
| 38 | Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => ({ | 47 | Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => { |
| 39 | decorate: (engine, identity: EngineStackIdentity) => | 48 | const balanceGate = makeExecutionLimitGate((organizationId) => |
| 40 | withExecutionUsageTracking(identity.organizationId, engine, (organizationId) => | 49 | autumn.checkExecutionBalance(organizationId), |
| 41 | Effect.runFork(autumn.trackExecution(organizationId)), | 50 | ); |
| 42 | ), | 51 | const rateLimiter = makeCloudExecutionRateLimiter(); |
| 43 | })), | 52 | return { |
| 53 | decorate: (engine, identity: EngineStackIdentity) => | ||
| 54 | rateLimiter.decorate( | ||
| 55 | identity.organizationId, | ||
| 56 | balanceGate.decorate( | ||
| 57 | identity.organizationId, | ||
| 58 | withExecutionUsageTracking(identity.organizationId, engine, (organizationId) => | ||
| 59 | Effect.runFork(autumn.trackExecution(organizationId)), | ||
| 60 | ), | ||
| 61 | ), | ||
| 62 | ), | ||
| 63 | }; | ||
| 64 | }), | ||
| 44 | ); | 65 | ); |
| 45 | 66 | ||
| 46 | /** | 67 | /** |
| @@ -32,6 +32,20 @@ declare global { · 31 unmodified lines | |||
| 32 | // ON; the test workers set "true" so fixtures can reach localhost. | 32 | // ON; the test workers set "true" so fixtures can reach localhost. |
| 33 | ALLOW_LOCAL_NETWORK?: string; | 33 | ALLOW_LOCAL_NETWORK?: string; |
| 34 | 34 | ||
| 35 | // Per-org execution rate-limit counter DO (wrangler.jsonc | ||
| 36 | // `durable_objects`). Declared optional here (matching the BLOBS | ||
| 37 | // precedent) rather than regenerating worker-configuration.d.ts: test | ||
| 38 | // workers and older local setups run without the binding, and the | ||
| 39 | // limiter degrades to disabled when absent. | ||
| 40 | EXECUTION_RATE_LIMITER?: import("@cloudflare/workers-types").DurableObjectNamespace; | ||
| 41 | |||
| 42 | // Optional per-org hourly execution rate-limit override, parsed as an | ||
| 43 | // integer (defaults to EXECUTIONS_PER_ORG_PER_HOUR = 1000 when unset or | ||
| 44 | // unparseable). Exists for e2e: the production cap of 1000/hour can't be | ||
| 45 | // exercised with real executions, so the e2e dev-server env sets a small | ||
| 46 | // number to drive the backstop. Production leaves it unset. | ||
| 47 | EXECUTION_RATE_LIMIT_PER_HOUR?: string; | ||
| 48 | |||
| 35 | // Billing | 49 | // Billing |
| 36 | AUTUMN_SECRET_KEY?: string; | 50 | AUTUMN_SECRET_KEY?: string; |
| 37 | /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */ | 51 | /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */ |
| @@ -22,6 +22,9 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ · 21 unmodified lines | |||
| 22 | 22 | ||
| 23 | export type IAutumnService = Readonly<{ | 23 | export type IAutumnService = Readonly<{ |
| 24 | use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>; | 24 | use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>; |
| 25 | checkExecutionBalance: ( | ||
| 26 | organizationId: string, | ||
| 27 | ) => Effect.Effect<{ readonly allowed: boolean }, AutumnError, never>; | ||
| 25 | /** | 28 | /** |
| 26 | * Fire-and-forget-safe execution usage tracker. Errors are caught and | 29 | * Fire-and-forget-safe execution usage tracker. Errors are caught and |
| 27 | * logged; the returned Effect never fails. Callers typically | 30 | * logged; the returned Effect never fails. Callers typically |
| @@ -44,6 +47,7 @@ const make = Effect.sync(() => { · 16 unmodified lines | |||
| 44 | ); | 47 | ); |
| 45 | return { | 48 | return { |
| 46 | use: () => notConfigured, | 49 | use: () => notConfigured, |
| 50 | checkExecutionBalance: () => notConfigured, | ||
| 47 | trackExecution: () => Effect.void, | 51 | trackExecution: () => Effect.void, |
| 48 | } satisfies IAutumnService; | 52 | } satisfies IAutumnService; |
| 49 | } | 53 | } |
| @@ -80,7 +84,16 @@ const make = Effect.sync(() => { · 30 unmodified lines | |||
| 80 | ); | 84 | ); |
| 81 | }).pipe(Effect.withSpan("autumn.trackExecution")); | 85 | }).pipe(Effect.withSpan("autumn.trackExecution")); |
| 82 | 86 | ||
| 83 | return { use, trackExecution } satisfies IAutumnService; | 87 | const checkExecutionBalance = (organizationId: string) => |
| 88 | Effect.gen(function* () { | ||
| 89 | yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); | ||
| 90 | const check = yield* use((c) => | ||
| 91 | c.check({ customerId: organizationId, featureId: "executions" }), | ||
| 92 | ); | ||
| 93 | return { allowed: check.allowed }; | ||
| 94 | }).pipe(Effect.withSpan("autumn.checkExecutionBalance")); | ||
| 95 | |||
| 96 | return { use, checkExecutionBalance, trackExecution } satisfies IAutumnService; | ||
| 84 | }); | 97 | }); |
| 85 | 98 | ||
| 86 | export class AutumnService extends Context.Service<AutumnService, IAutumnService>()( | 99 | export class AutumnService extends Context.Service<AutumnService, IAutumnService>()( |
| @@ -55,6 +55,11 @@ export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( · 54 unmodified lines | |||
| 55 | // (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it. | 55 | // (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it. |
| 56 | export class McpSessionDO extends DurableObject {} | 56 | export class McpSessionDO extends DurableObject {} |
| 57 | 57 | ||
| 58 | // Per-org execution rate-limit counter DO (abuse backstop; migration v3, | ||
| 59 | // `EXECUTION_RATE_LIMITER` binding). Plain counter, no Sentry wrapper needed: | ||
| 60 | // its callers already fail open and report errors themselves. | ||
| 61 | export { ExecutionRateLimiterDO } from "./engine/execution-rate-limit"; | ||
| 62 | |||
| 58 | // --------------------------------------------------------------------------- | 63 | // --------------------------------------------------------------------------- |
| 59 | // Worker fetch handler | 64 | // Worker fetch handler |
| 60 | // | 65 | // |
| @@ -13,7 +13,7 @@ const supportLinks = [ · 12 unmodified lines | |||
| 13 | { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark }, | 13 | { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark }, |
| 14 | { | 14 | { |
| 15 | label: "GitHub Issues", | 15 | label: "GitHub Issues", |
| 16 | href: "https://github.com/RhysSullivan/executor/issues", | 16 | href: "https://github.com/UsefulSoftwareCo/executor/issues", |
| 17 | icon: GitHubMark, | 17 | icon: GitHubMark, |
| 18 | }, | 18 | }, |
| 19 | { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark }, | 19 | { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark }, |
| @@ -22,6 +22,13 @@ · 21 unmodified lines | |||
| 22 | "name": "MCP_SESSION", | 22 | "name": "MCP_SESSION", |
| 23 | "class_name": "McpSessionDOSqlite", | 23 | "class_name": "McpSessionDOSqlite", |
| 24 | }, | 24 | }, |
| 25 | // Per-org execution rate-limit counter (abuse backstop). One instance | ||
| 26 | // per organization via idFromName(orgId); a single { windowId, count } | ||
| 27 | // record per instance, purged by alarm after inactivity. | ||
| 28 | { | ||
| 29 | "name": "EXECUTION_RATE_LIMITER", | ||
| 30 | "class_name": "ExecutionRateLimiterDO", | ||
| 31 | }, | ||
| 25 | ], | 32 | ], |
| 26 | }, | 33 | }, |
| 27 | // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which | 34 | // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which |
| @@ -42,6 +49,10 @@ · 14 unmodified lines | |||
| 42 | "tag": "v2", | 49 | "tag": "v2", |
| 43 | "new_sqlite_classes": ["McpSessionDOSqlite"], | 50 | "new_sqlite_classes": ["McpSessionDOSqlite"], |
| 44 | }, | 51 | }, |
| 52 | { | ||
| 53 | "tag": "v3", | ||
| 54 | "new_sqlite_classes": ["ExecutionRateLimiterDO"], | ||
| 55 | }, | ||
| 45 | ], | 56 | ], |
| 46 | "services": [ | 57 | "services": [ |
| 47 | { | 58 | { |
| @@ -57,7 +57,7 @@ const config: Configuration = { · 56 unmodified lines | |||
| 57 | }, | 57 | }, |
| 58 | publish: { | 58 | publish: { |
| 59 | provider: "github", | 59 | provider: "github", |
| 60 | owner: "RhysSullivan", | 60 | owner: "UsefulSoftwareCo", |
| 61 | repo: "executor", | 61 | repo: "executor", |
| 62 | }, | 62 | }, |
| 63 | }; | 63 | }; |
| @@ -53,7 +53,9 @@ export default defineConfig({ · 52 unmodified lines | |||
| 53 | "import.meta.env.VITE_APP_VERSION": JSON.stringify( | 53 | "import.meta.env.VITE_APP_VERSION": JSON.stringify( |
| 54 | process.env.npm_package_version ?? "0.0.0", | 54 | process.env.npm_package_version ?? "0.0.0", |
| 55 | ), | 55 | ), |
| 56 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), | 56 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify( |
| 57 | "https://github.com/UsefulSoftwareCo/executor", | ||
| 58 | ), | ||
| 57 | "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify( | 59 | "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify( |
| 58 | process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh", | 60 | process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh", |
| 59 | ), | 61 | ), |
| @@ -2,7 +2,7 @@ · 1 unmodified line | |||
| 2 | "name": "@executor-js/desktop", | 2 | "name": "@executor-js/desktop", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "homepage": "https://github.com/RhysSullivan/executor", | 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor", |
| 6 | "license": "MIT", | 6 | "license": "MIT", |
| 7 | "type": "module", | 7 | "type": "module", |
| 8 | "main": "./out/main/index.js", | 8 | "main": "./out/main/index.js", |
| @@ -259,7 +259,7 @@ export const reportAProblem = async () => { · 258 unmodified lines | |||
| 259 | "", | 259 | "", |
| 260 | "_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._", | 260 | "_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._", |
| 261 | ].join("\n"); | 261 | ].join("\n"); |
| 262 | const url = new URL("https://github.com/RhysSullivan/executor/issues/new"); | 262 | const url = new URL("https://github.com/UsefulSoftwareCo/executor/issues/new"); |
| 263 | url.searchParams.set("title", "[desktop] "); | 263 | url.searchParams.set("title", "[desktop] "); |
| 264 | url.searchParams.set("body", body); | 264 | url.searchParams.set("body", body); |
| 265 | await shell.openExternal(url.toString()); | 265 | await shell.openExternal(url.toString()); |
| @@ -45,7 +45,7 @@ · 44 unmodified lines | |||
| 45 | "links": [ | 45 | "links": [ |
| 46 | { | 46 | { |
| 47 | "label": "GitHub", | 47 | "label": "GitHub", |
| 48 | "href": "https://github.com/RhysSullivan/executor" | 48 | "href": "https://github.com/UsefulSoftwareCo/executor" |
| 49 | } | 49 | } |
| 50 | ], | 50 | ], |
| 51 | "primary": { | 51 | "primary": { |
| @@ -56,7 +56,7 @@ · 4 unmodified lines | |||
| 56 | }, | 56 | }, |
| 57 | "footer": { | 57 | "footer": { |
| 58 | "socials": { | 58 | "socials": { |
| 59 | "github": "https://github.com/RhysSullivan/executor" | 59 | "github": "https://github.com/UsefulSoftwareCo/executor" |
| 60 | } | 60 | } |
| 61 | } | 61 | } |
| 62 | } | 62 | } |
| @@ -15,7 +15,7 @@ and you manage members and credentials in Cloudflare Access rather than in the a · 14 unmodified lines | |||
| 15 | ## Prerequisites | 15 | ## Prerequisites |
| 16 | 16 | ||
| 17 | - A Cloudflare account with Workers and Zero Trust (Access) enabled. | 17 | - A Cloudflare account with Workers and Zero Trust (Access) enabled. |
| 18 | - The [Executor repo](https://github.com/RhysSullivan/executor) checked out, with | 18 | - The [Executor repo](https://github.com/UsefulSoftwareCo/executor) checked out, with |
| 19 | dependencies installed (`bun install`). | 19 | dependencies installed (`bun install`). |
| 20 | 20 | ||
| 21 | ## Deploy | 21 | ## Deploy |
| @@ -17,10 +17,10 @@ docker run -d \ · 16 unmodified lines | |||
| 17 | --name executor-selfhost \ | 17 | --name executor-selfhost \ |
| 18 | -p 4788:4788 \ | 18 | -p 4788:4788 \ |
| 19 | -v executor-data:/data \ | 19 | -v executor-data:/data \ |
| 20 | ghcr.io/rhyssullivan/executor-selfhost:latest | 20 | ghcr.io/usefulsoftwareco/executor-selfhost:latest |
| 21 | ``` | 21 | ``` |
| 22 | 22 | ||
| 23 | Or build from a clone of the [repo](https://github.com/RhysSullivan/executor), | 23 | Or build from a clone of the [repo](https://github.com/UsefulSoftwareCo/executor), |
| 24 | from `apps/host-selfhost`: | 24 | from `apps/host-selfhost`: |
| 25 | 25 | ||
| 26 | ```bash | 26 | ```bash |
| @@ -81,7 +81,7 @@ docker run -d \ · 54 unmodified lines | |||
| 81 | -p 4788:4788 \ | 81 | -p 4788:4788 \ |
| 82 | -v executor-data:/data \ | 82 | -v executor-data:/data \ |
| 83 | -e EXECUTOR_WEB_BASE_URL=https://executor.example.com \ | 83 | -e EXECUTOR_WEB_BASE_URL=https://executor.example.com \ |
| 84 | ghcr.io/rhyssullivan/executor-selfhost:latest | 84 | ghcr.io/usefulsoftwareco/executor-selfhost:latest |
| 85 | ``` | 85 | ``` |
| 86 | 86 | ||
| 87 | For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | 87 | For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` |
| @@ -54,7 +54,7 @@ Once you know which form I want: · 53 unmodified lines | |||
| 54 | 3. Once the tools are available, help me add my first integration and get one tool working end to end. | 54 | 3. Once the tools are available, help me add my first integration and get one tool working end to end. |
| 55 | 55 | ||
| 56 | Docs: https://executor.sh/docs | 56 | Docs: https://executor.sh/docs |
| 57 | Source (and the place to start if something breaks): https://github.com/RhysSullivan/executor | 57 | Source (and the place to start if something breaks): https://github.com/UsefulSoftwareCo/executor |
| 58 | ``` | 58 | ``` |
| 59 | 59 | ||
| 60 | ## Concepts | 60 | ## Concepts |
| @@ -50,7 +50,9 @@ export default defineConfig({ · 49 unmodified lines | |||
| 50 | // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm, | 50 | // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm, |
| 51 | // so the update card links to the upgrade guide instead of a command. | 51 | // so the update card links to the upgrade guide instead of a command. |
| 52 | "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"), | 52 | "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"), |
| 53 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), | 53 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify( |
| 54 | "https://github.com/UsefulSoftwareCo/executor", | ||
| 55 | ), | ||
| 54 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"), | 56 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"), |
| 55 | }, | 57 | }, |
| 56 | server: { | 58 | server: { |
| @@ -23,7 +23,7 @@ RUN cd apps/host-selfhost && bun run build · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | FROM gcr.io/distroless/cc-debian12 AS runtime | 24 | FROM gcr.io/distroless/cc-debian12 AS runtime |
| 25 | WORKDIR /app | 25 | WORKDIR /app |
| 26 | LABEL org.opencontainers.image.source="https://github.com/RhysSullivan/executor" \ | 26 | LABEL org.opencontainers.image.source="https://github.com/UsefulSoftwareCo/executor" \ |
| 27 | org.opencontainers.image.description="Single-container self-hosted Executor" \ | 27 | org.opencontainers.image.description="Single-container self-hosted Executor" \ |
| 28 | org.opencontainers.image.licenses="MIT" | 28 | org.opencontainers.image.licenses="MIT" |
| 29 | ENV NODE_ENV=production \ | 29 | ENV NODE_ENV=production \ |
| @@ -14,7 +14,7 @@ docker run -d \ · 13 unmodified lines | |||
| 14 | --name executor-selfhost \ | 14 | --name executor-selfhost \ |
| 15 | -p 4788:4788 \ | 15 | -p 4788:4788 \ |
| 16 | -v executor-data:/data \ | 16 | -v executor-data:/data \ |
| 17 | ghcr.io/rhyssullivan/executor-selfhost:latest | 17 | ghcr.io/usefulsoftwareco/executor-selfhost:latest |
| 18 | ``` | 18 | ``` |
| 19 | 19 | ||
| 20 | Or build from a repository clone: | 20 | Or build from a repository clone: |
| @@ -218,7 +218,9 @@ export default defineConfig({ · 217 unmodified lines | |||
| 218 | // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not | 218 | // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not |
| 219 | // npm, so the update card links to the upgrade guide instead of a command. | 219 | // npm, so the update card links to the upgrade guide instead of a command. |
| 220 | "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"), | 220 | "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"), |
| 221 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), | 221 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify( |
| 222 | "https://github.com/UsefulSoftwareCo/executor", | ||
| 223 | ), | ||
| 222 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), | 224 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), |
| 223 | }, | 225 | }, |
| 224 | server: { | 226 | server: { |
| @@ -0,0 +1 @@ | |||
| 1 | ALTER TABLE `oauth_client` ADD `origin_issuer` text; | ||
| @@ -0,0 +1,920 @@ | |||
| 1 | { | ||
| 2 | "version": "6", | ||
| 3 | "dialect": "sqlite", | ||
| 4 | "id": "80ff1a5d-ab69-416c-868e-2bf6609fa5b4", | ||
| 5 | "prevId": "03321915-aef6-4791-9558-7fc82273af05", | ||
| 6 | "tables": { | ||
| 7 | "blob": { | ||
| 8 | "name": "blob", | ||
| 9 | "columns": { | ||
| 10 | "namespace": { | ||
| 11 | "name": "namespace", | ||
| 12 | "type": "text", | ||
| 13 | "primaryKey": false, | ||
| 14 | "notNull": true, | ||
| 15 | "autoincrement": false | ||
| 16 | }, | ||
| 17 | "key": { | ||
| 18 | "name": "key", | ||
| 19 | "type": "text", | ||
| 20 | "primaryKey": false, | ||
| 21 | "notNull": true, | ||
| 22 | "autoincrement": false | ||
| 23 | }, | ||
| 24 | "value": { | ||
| 25 | "name": "value", | ||
| 26 | "type": "text", | ||
| 27 | "primaryKey": false, | ||
| 28 | "notNull": true, | ||
| 29 | "autoincrement": false | ||
| 30 | }, | ||
| 31 | "row_id": { | ||
| 32 | "name": "row_id", | ||
| 33 | "type": "text", | ||
| 34 | "primaryKey": true, | ||
| 35 | "notNull": true, | ||
| 36 | "autoincrement": false | ||
| 37 | }, | ||
| 38 | "id": { | ||
| 39 | "name": "id", | ||
| 40 | "type": "text", | ||
| 41 | "primaryKey": false, | ||
| 42 | "notNull": true, | ||
| 43 | "autoincrement": false | ||
| 44 | } | ||
| 45 | }, | ||
| 46 | "indexes": { | ||
| 47 | "blob_id_uidx": { | ||
| 48 | "name": "blob_id_uidx", | ||
| 49 | "columns": ["id"], | ||
| 50 | "isUnique": true | ||
| 51 | } | ||
| 52 | }, | ||
| 53 | "foreignKeys": {}, | ||
| 54 | "compositePrimaryKeys": {}, | ||
| 55 | "uniqueConstraints": {}, | ||
| 56 | "checkConstraints": {} | ||
| 57 | }, | ||
| 58 | "connection": { | ||
| 59 | "name": "connection", | ||
| 60 | "columns": { | ||
| 61 | "integration": { | ||
| 62 | "name": "integration", | ||
| 63 | "type": "text", | ||
| 64 | "primaryKey": false, | ||
| 65 | "notNull": true, | ||
| 66 | "autoincrement": false | ||
| 67 | }, | ||
| 68 | "name": { | ||
| 69 | "name": "name", | ||
| 70 | "type": "text", | ||
| 71 | "primaryKey": false, | ||
| 72 | "notNull": true, | ||
| 73 | "autoincrement": false | ||
| 74 | }, | ||
| 75 | "template": { | ||
| 76 | "name": "template", | ||
| 77 | "type": "text", | ||
| 78 | "primaryKey": false, | ||
| 79 | "notNull": true, | ||
| 80 | "autoincrement": false | ||
| 81 | }, | ||
| 82 | "provider": { | ||
| 83 | "name": "provider", | ||
| 84 | "type": "text", | ||
| 85 | "primaryKey": false, | ||
| 86 | "notNull": true, | ||
| 87 | "autoincrement": false | ||
| 88 | }, | ||
| 89 | "item_ids": { | ||
| 90 | "name": "item_ids", | ||
| 91 | "type": "text", | ||
| 92 | "primaryKey": false, | ||
| 93 | "notNull": true, | ||
| 94 | "autoincrement": false | ||
| 95 | }, | ||
| 96 | "identity_label": { | ||
| 97 | "name": "identity_label", | ||
| 98 | "type": "text", | ||
| 99 | "primaryKey": false, | ||
| 100 | "notNull": false, | ||
| 101 | "autoincrement": false | ||
| 102 | }, | ||
| 103 | "oauth_client": { | ||
| 104 | "name": "oauth_client", | ||
| 105 | "type": "text", | ||
| 106 | "primaryKey": false, | ||
| 107 | "notNull": false, | ||
| 108 | "autoincrement": false | ||
| 109 | }, | ||
| 110 | "oauth_client_owner": { | ||
| 111 | "name": "oauth_client_owner", | ||
| 112 | "type": "text", | ||
| 113 | "primaryKey": false, | ||
| 114 | "notNull": false, | ||
| 115 | "autoincrement": false | ||
| 116 | }, | ||
| 117 | "refresh_item_id": { | ||
| 118 | "name": "refresh_item_id", | ||
| 119 | "type": "text", | ||
| 120 | "primaryKey": false, | ||
| 121 | "notNull": false, | ||
| 122 | "autoincrement": false | ||
| 123 | }, | ||
| 124 | "expires_at": { | ||
| 125 | "name": "expires_at", | ||
| 126 | "type": "blob", | ||
| 127 | "primaryKey": false, | ||
| 128 | "notNull": false, | ||
| 129 | "autoincrement": false | ||
| 130 | }, | ||
| 131 | "oauth_scope": { | ||
| 132 | "name": "oauth_scope", | ||
| 133 | "type": "text", | ||
| 134 | "primaryKey": false, | ||
| 135 | "notNull": false, | ||
| 136 | "autoincrement": false | ||
| 137 | }, | ||
| 138 | "oauth_token_url": { | ||
| 139 | "name": "oauth_token_url", | ||
| 140 | "type": "text", | ||
| 141 | "primaryKey": false, | ||
| 142 | "notNull": false, | ||
| 143 | "autoincrement": false | ||
| 144 | }, | ||
| 145 | "provider_state": { | ||
| 146 | "name": "provider_state", | ||
| 147 | "type": "text", | ||
| 148 | "primaryKey": false, | ||
| 149 | "notNull": false, | ||
| 150 | "autoincrement": false | ||
| 151 | }, | ||
| 152 | "created_at": { | ||
| 153 | "name": "created_at", | ||
| 154 | "type": "integer", | ||
| 155 | "primaryKey": false, | ||
| 156 | "notNull": true, | ||
| 157 | "autoincrement": false | ||
| 158 | }, | ||
| 159 | "updated_at": { | ||
| 160 | "name": "updated_at", | ||
| 161 | "type": "integer", | ||
| 162 | "primaryKey": false, | ||
| 163 | "notNull": true, | ||
| 164 | "autoincrement": false | ||
| 165 | }, | ||
| 166 | "row_id": { | ||
| 167 | "name": "row_id", | ||
| 168 | "type": "text", | ||
| 169 | "primaryKey": true, | ||
| 170 | "notNull": true, | ||
| 171 | "autoincrement": false | ||
| 172 | }, | ||
| 173 | "tenant": { | ||
| 174 | "name": "tenant", | ||
| 175 | "type": "text", | ||
| 176 | "primaryKey": false, | ||
| 177 | "notNull": true, | ||
| 178 | "autoincrement": false | ||
| 179 | }, | ||
| 180 | "owner": { | ||
| 181 | "name": "owner", | ||
| 182 | "type": "text", | ||
| 183 | "primaryKey": false, | ||
| 184 | "notNull": true, | ||
| 185 | "autoincrement": false | ||
| 186 | }, | ||
| 187 | "subject": { | ||
| 188 | "name": "subject", | ||
| 189 | "type": "text", | ||
| 190 | "primaryKey": false, | ||
| 191 | "notNull": true, | ||
| 192 | "autoincrement": false | ||
| 193 | } | ||
| 194 | }, | ||
| 195 | "indexes": { | ||
| 196 | "connection_uidx": { | ||
| 197 | "name": "connection_uidx", | ||
| 198 | "columns": ["tenant", "owner", "subject", "integration", "name"], | ||
| 199 | "isUnique": true | ||
| 200 | } | ||
| 201 | }, | ||
| 202 | "foreignKeys": {}, | ||
| 203 | "compositePrimaryKeys": {}, | ||
| 204 | "uniqueConstraints": {}, | ||
| 205 | "checkConstraints": {} | ||
| 206 | }, | ||
| 207 | "definition": { | ||
| 208 | "name": "definition", | ||
| 209 | "columns": { | ||
| 210 | "integration": { | ||
| 211 | "name": "integration", | ||
| 212 | "type": "text", | ||
| 213 | "primaryKey": false, | ||
| 214 | "notNull": true, | ||
| 215 | "autoincrement": false | ||
| 216 | }, | ||
| 217 | "connection": { | ||
| 218 | "name": "connection", | ||
| 219 | "type": "text", | ||
| 220 | "primaryKey": false, | ||
| 221 | "notNull": true, | ||
| 222 | "autoincrement": false | ||
| 223 | }, | ||
| 224 | "plugin_id": { | ||
| 225 | "name": "plugin_id", | ||
| 226 | "type": "text", | ||
| 227 | "primaryKey": false, | ||
| 228 | "notNull": true, | ||
| 229 | "autoincrement": false | ||
| 230 | }, | ||
| 231 | "name": { | ||
| 232 | "name": "name", | ||
| 233 | "type": "text", | ||
| 234 | "primaryKey": false, | ||
| 235 | "notNull": true, | ||
| 236 | "autoincrement": false | ||
| 237 | }, | ||
| 238 | "schema": { | ||
| 239 | "name": "schema", | ||
| 240 | "type": "text", | ||
| 241 | "primaryKey": false, | ||
| 242 | "notNull": true, | ||
| 243 | "autoincrement": false | ||
| 244 | }, | ||
| 245 | "created_at": { | ||
| 246 | "name": "created_at", | ||
| 247 | "type": "integer", | ||
| 248 | "primaryKey": false, | ||
| 249 | "notNull": true, | ||
| 250 | "autoincrement": false | ||
| 251 | }, | ||
| 252 | "row_id": { | ||
| 253 | "name": "row_id", | ||
| 254 | "type": "text", | ||
| 255 | "primaryKey": true, | ||
| 256 | "notNull": true, | ||
| 257 | "autoincrement": false | ||
| 258 | }, | ||
| 259 | "tenant": { | ||
| 260 | "name": "tenant", | ||
| 261 | "type": "text", | ||
| 262 | "primaryKey": false, | ||
| 263 | "notNull": true, | ||
| 264 | "autoincrement": false | ||
| 265 | }, | ||
| 266 | "owner": { | ||
| 267 | "name": "owner", | ||
| 268 | "type": "text", | ||
| 269 | "primaryKey": false, | ||
| 270 | "notNull": true, | ||
| 271 | "autoincrement": false | ||
| 272 | }, | ||
| 273 | "subject": { | ||
| 274 | "name": "subject", | ||
| 275 | "type": "text", | ||
| 276 | "primaryKey": false, | ||
| 277 | "notNull": true, | ||
| 278 | "autoincrement": false | ||
| 279 | } | ||
| 280 | }, | ||
| 281 | "indexes": { | ||
| 282 | "definition_uidx": { | ||
| 283 | "name": "definition_uidx", | ||
| 284 | "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], | ||
| 285 | "isUnique": true | ||
| 286 | } | ||
| 287 | }, | ||
| 288 | "foreignKeys": {}, | ||
| 289 | "compositePrimaryKeys": {}, | ||
| 290 | "uniqueConstraints": {}, | ||
| 291 | "checkConstraints": {} | ||
| 292 | }, | ||
| 293 | "integration": { | ||
| 294 | "name": "integration", | ||
| 295 | "columns": { | ||
| 296 | "slug": { | ||
| 297 | "name": "slug", | ||
| 298 | "type": "text", | ||
| 299 | "primaryKey": false, | ||
| 300 | "notNull": true, | ||
| 301 | "autoincrement": false | ||
| 302 | }, | ||
| 303 | "plugin_id": { | ||
| 304 | "name": "plugin_id", | ||
| 305 | "type": "text", | ||
| 306 | "primaryKey": false, | ||
| 307 | "notNull": true, | ||
| 308 | "autoincrement": false | ||
| 309 | }, | ||
| 310 | "description": { | ||
| 311 | "name": "description", | ||
| 312 | "type": "text", | ||
| 313 | "primaryKey": false, | ||
| 314 | "notNull": true, | ||
| 315 | "autoincrement": false | ||
| 316 | }, | ||
| 317 | "config": { | ||
| 318 | "name": "config", | ||
| 319 | "type": "text", | ||
| 320 | "primaryKey": false, | ||
| 321 | "notNull": false, | ||
| 322 | "autoincrement": false | ||
| 323 | }, | ||
| 324 | "can_remove": { | ||
| 325 | "name": "can_remove", | ||
| 326 | "type": "integer", | ||
| 327 | "primaryKey": false, | ||
| 328 | "notNull": true, | ||
| 329 | "autoincrement": false, | ||
| 330 | "default": 1 | ||
| 331 | }, | ||
| 332 | "can_refresh": { | ||
| 333 | "name": "can_refresh", | ||
| 334 | "type": "integer", | ||
| 335 | "primaryKey": false, | ||
| 336 | "notNull": true, | ||
| 337 | "autoincrement": false, | ||
| 338 | "default": 0 | ||
| 339 | }, | ||
| 340 | "created_at": { | ||
| 341 | "name": "created_at", | ||
| 342 | "type": "integer", | ||
| 343 | "primaryKey": false, | ||
| 344 | "notNull": true, | ||
| 345 | "autoincrement": false | ||
| 346 | }, | ||
| 347 | "updated_at": { | ||
| 348 | "name": "updated_at", | ||
| 349 | "type": "integer", | ||
| 350 | "primaryKey": false, | ||
| 351 | "notNull": true, | ||
| 352 | "autoincrement": false | ||
| 353 | }, | ||
| 354 | "row_id": { | ||
| 355 | "name": "row_id", | ||
| 356 | "type": "text", | ||
| 357 | "primaryKey": true, | ||
| 358 | "notNull": true, | ||
| 359 | "autoincrement": false | ||
| 360 | }, | ||
| 361 | "tenant": { | ||
| 362 | "name": "tenant", | ||
| 363 | "type": "text", | ||
| 364 | "primaryKey": false, | ||
| 365 | "notNull": true, | ||
| 366 | "autoincrement": false | ||
| 367 | } | ||
| 368 | }, | ||
| 369 | "indexes": { | ||
| 370 | "integration_uidx": { | ||
| 371 | "name": "integration_uidx", | ||
| 372 | "columns": ["tenant", "slug"], | ||
| 373 | "isUnique": true | ||
| 374 | } | ||
| 375 | }, | ||
| 376 | "foreignKeys": {}, | ||
| 377 | "compositePrimaryKeys": {}, | ||
| 378 | "uniqueConstraints": {}, | ||
| 379 | "checkConstraints": {} | ||
| 380 | }, | ||
| 381 | "oauth_client": { | ||
| 382 | "name": "oauth_client", | ||
| 383 | "columns": { | ||
| 384 | "slug": { | ||
| 385 | "name": "slug", | ||
| 386 | "type": "text", | ||
| 387 | "primaryKey": false, | ||
| 388 | "notNull": true, | ||
| 389 | "autoincrement": false | ||
| 390 | }, | ||
| 391 | "authorization_url": { | ||
| 392 | "name": "authorization_url", | ||
| 393 | "type": "text", | ||
| 394 | "primaryKey": false, | ||
| 395 | "notNull": true, | ||
| 396 | "autoincrement": false | ||
| 397 | }, | ||
| 398 | "token_url": { | ||
| 399 | "name": "token_url", | ||
| 400 | "type": "text", | ||
| 401 | "primaryKey": false, | ||
| 402 | "notNull": true, | ||
| 403 | "autoincrement": false | ||
| 404 | }, | ||
| 405 | "grant": { | ||
| 406 | "name": "grant", | ||
| 407 | "type": "text", | ||
| 408 | "primaryKey": false, | ||
| 409 | "notNull": true, | ||
| 410 | "autoincrement": false | ||
| 411 | }, | ||
| 412 | "client_id": { | ||
| 413 | "name": "client_id", | ||
| 414 | "type": "text", | ||
| 415 | "primaryKey": false, | ||
| 416 | "notNull": true, | ||
| 417 | "autoincrement": false | ||
| 418 | }, | ||
| 419 | "client_secret_item_id": { | ||
| 420 | "name": "client_secret_item_id", | ||
| 421 | "type": "text", | ||
| 422 | "primaryKey": false, | ||
| 423 | "notNull": false, | ||
| 424 | "autoincrement": false | ||
| 425 | }, | ||
| 426 | "resource": { | ||
| 427 | "name": "resource", | ||
| 428 | "type": "text", | ||
| 429 | "primaryKey": false, | ||
| 430 | "notNull": false, | ||
| 431 | "autoincrement": false | ||
| 432 | }, | ||
| 433 | "origin_kind": { | ||
| 434 | "name": "origin_kind", | ||
| 435 | "type": "text", | ||
| 436 | "primaryKey": false, | ||
| 437 | "notNull": false, | ||
| 438 | "autoincrement": false | ||
| 439 | }, | ||
| 440 | "origin_integration": { | ||
| 441 | "name": "origin_integration", | ||
| 442 | "type": "text", | ||
| 443 | "primaryKey": false, | ||
| 444 | "notNull": false, | ||
| 445 | "autoincrement": false | ||
| 446 | }, | ||
| 447 | "origin_issuer": { | ||
| 448 | "name": "origin_issuer", | ||
| 449 | "type": "text", | ||
| 450 | "primaryKey": false, | ||
| 451 | "notNull": false, | ||
| 452 | "autoincrement": false | ||
| 453 | }, | ||
| 454 | "created_at": { | ||
| 455 | "name": "created_at", | ||
| 456 | "type": "integer", | ||
| 457 | "primaryKey": false, | ||
| 458 | "notNull": true, | ||
| 459 | "autoincrement": false | ||
| 460 | }, | ||
| 461 | "row_id": { | ||
| 462 | "name": "row_id", | ||
| 463 | "type": "text", | ||
| 464 | "primaryKey": true, | ||
| 465 | "notNull": true, | ||
| 466 | "autoincrement": false | ||
| 467 | }, | ||
| 468 | "tenant": { | ||
| 469 | "name": "tenant", | ||
| 470 | "type": "text", | ||
| 471 | "primaryKey": false, | ||
| 472 | "notNull": true, | ||
| 473 | "autoincrement": false | ||
| 474 | }, | ||
| 475 | "owner": { | ||
| 476 | "name": "owner", | ||
| 477 | "type": "text", | ||
| 478 | "primaryKey": false, | ||
| 479 | "notNull": true, | ||
| 480 | "autoincrement": false | ||
| 481 | }, | ||
| 482 | "subject": { | ||
| 483 | "name": "subject", | ||
| 484 | "type": "text", | ||
| 485 | "primaryKey": false, | ||
| 486 | "notNull": true, | ||
| 487 | "autoincrement": false | ||
| 488 | } | ||
| 489 | }, | ||
| 490 | "indexes": { | ||
| 491 | "oauth_client_uidx": { | ||
| 492 | "name": "oauth_client_uidx", | ||
| 493 | "columns": ["tenant", "owner", "subject", "slug"], | ||
| 494 | "isUnique": true | ||
| 495 | } | ||
| 496 | }, | ||
| 497 | "foreignKeys": {}, | ||
| 498 | "compositePrimaryKeys": {}, | ||
| 499 | "uniqueConstraints": {}, | ||
| 500 | "checkConstraints": {} | ||
| 501 | }, | ||
| 502 | "oauth_session": { | ||
| 503 | "name": "oauth_session", | ||
| 504 | "columns": { | ||
| 505 | "state": { | ||
| 506 | "name": "state", | ||
| 507 | "type": "text", | ||
| 508 | "primaryKey": false, | ||
| 509 | "notNull": true, | ||
| 510 | "autoincrement": false | ||
| 511 | }, | ||
| 512 | "client_slug": { | ||
| 513 | "name": "client_slug", | ||
| 514 | "type": "text", | ||
| 515 | "primaryKey": false, | ||
| 516 | "notNull": true, | ||
| 517 | "autoincrement": false | ||
| 518 | }, | ||
| 519 | "integration": { | ||
| 520 | "name": "integration", | ||
| 521 | "type": "text", | ||
| 522 | "primaryKey": false, | ||
| 523 | "notNull": true, | ||
| 524 | "autoincrement": false | ||
| 525 | }, | ||
| 526 | "name": { | ||
| 527 | "name": "name", | ||
| 528 | "type": "text", | ||
| 529 | "primaryKey": false, | ||
| 530 | "notNull": true, | ||
| 531 | "autoincrement": false | ||
| 532 | }, | ||
| 533 | "template": { | ||
| 534 | "name": "template", | ||
| 535 | "type": "text", | ||
| 536 | "primaryKey": false, | ||
| 537 | "notNull": true, | ||
| 538 | "autoincrement": false | ||
| 539 | }, | ||
| 540 | "redirect_url": { | ||
| 541 | "name": "redirect_url", | ||
| 542 | "type": "text", | ||
| 543 | "primaryKey": false, | ||
| 544 | "notNull": true, | ||
| 545 | "autoincrement": false | ||
| 546 | }, | ||
| 547 | "pkce_verifier": { | ||
| 548 | "name": "pkce_verifier", | ||
| 549 | "type": "text", | ||
| 550 | "primaryKey": false, | ||
| 551 | "notNull": false, | ||
| 552 | "autoincrement": false | ||
| 553 | }, | ||
| 554 | "identity_label": { | ||
| 555 | "name": "identity_label", | ||
| 556 | "type": "text", | ||
| 557 | "primaryKey": false, | ||
| 558 | "notNull": false, | ||
| 559 | "autoincrement": false | ||
| 560 | }, | ||
| 561 | "payload": { | ||
| 562 | "name": "payload", | ||
| 563 | "type": "text", | ||
| 564 | "primaryKey": false, | ||
| 565 | "notNull": true, | ||
| 566 | "autoincrement": false | ||
| 567 | }, | ||
| 568 | "expires_at": { | ||
| 569 | "name": "expires_at", | ||
| 570 | "type": "blob", | ||
| 571 | "primaryKey": false, | ||
| 572 | "notNull": true, | ||
| 573 | "autoincrement": false | ||
| 574 | }, | ||
| 575 | "created_at": { | ||
| 576 | "name": "created_at", | ||
| 577 | "type": "integer", | ||
| 578 | "primaryKey": false, | ||
| 579 | "notNull": true, | ||
| 580 | "autoincrement": false | ||
| 581 | }, | ||
| 582 | "row_id": { | ||
| 583 | "name": "row_id", | ||
| 584 | "type": "text", | ||
| 585 | "primaryKey": true, | ||
| 586 | "notNull": true, | ||
| 587 | "autoincrement": false | ||
| 588 | }, | ||
| 589 | "tenant": { | ||
| 590 | "name": "tenant", | ||
| 591 | "type": "text", | ||
| 592 | "primaryKey": false, | ||
| 593 | "notNull": true, | ||
| 594 | "autoincrement": false | ||
| 595 | }, | ||
| 596 | "owner": { | ||
| 597 | "name": "owner", | ||
| 598 | "type": "text", | ||
| 599 | "primaryKey": false, | ||
| 600 | "notNull": true, | ||
| 601 | "autoincrement": false | ||
| 602 | }, | ||
| 603 | "subject": { | ||
| 604 | "name": "subject", | ||
| 605 | "type": "text", | ||
| 606 | "primaryKey": false, | ||
| 607 | "notNull": true, | ||
| 608 | "autoincrement": false | ||
| 609 | } | ||
| 610 | }, | ||
| 611 | "indexes": { | ||
| 612 | "oauth_session_uidx": { | ||
| 613 | "name": "oauth_session_uidx", | ||
| 614 | "columns": ["tenant", "state"], | ||
| 615 | "isUnique": true | ||
| 616 | } | ||
| 617 | }, | ||
| 618 | "foreignKeys": {}, | ||
| 619 | "compositePrimaryKeys": {}, | ||
| 620 | "uniqueConstraints": {}, | ||
| 621 | "checkConstraints": {} | ||
| 622 | }, | ||
| 623 | "plugin_storage": { | ||
| 624 | "name": "plugin_storage", | ||
| 625 | "columns": { | ||
| 626 | "plugin_id": { | ||
| 627 | "name": "plugin_id", | ||
| 628 | "type": "text", | ||
| 629 | "primaryKey": false, | ||
| 630 | "notNull": true, | ||
| 631 | "autoincrement": false | ||
| 632 | }, | ||
| 633 | "collection": { | ||
| 634 | "name": "collection", | ||
| 635 | "type": "text", | ||
| 636 | "primaryKey": false, | ||
| 637 | "notNull": true, | ||
| 638 | "autoincrement": false | ||
| 639 | }, | ||
| 640 | "key": { | ||
| 641 | "name": "key", | ||
| 642 | "type": "text", | ||
| 643 | "primaryKey": false, | ||
| 644 | "notNull": true, | ||
| 645 | "autoincrement": false | ||
| 646 | }, | ||
| 647 | "data": { | ||
| 648 | "name": "data", | ||
| 649 | "type": "text", | ||
| 650 | "primaryKey": false, | ||
| 651 | "notNull": true, | ||
| 652 | "autoincrement": false | ||
| 653 | }, | ||
| 654 | "created_at": { | ||
| 655 | "name": "created_at", | ||
| 656 | "type": "integer", | ||
| 657 | "primaryKey": false, | ||
| 658 | "notNull": true, | ||
| 659 | "autoincrement": false | ||
| 660 | }, | ||
| 661 | "updated_at": { | ||
| 662 | "name": "updated_at", | ||
| 663 | "type": "integer", | ||
| 664 | "primaryKey": false, | ||
| 665 | "notNull": true, | ||
| 666 | "autoincrement": false | ||
| 667 | }, | ||
| 668 | "row_id": { | ||
| 669 | "name": "row_id", | ||
| 670 | "type": "text", | ||
| 671 | "primaryKey": true, | ||
| 672 | "notNull": true, | ||
| 673 | "autoincrement": false | ||
| 674 | }, | ||
| 675 | "tenant": { | ||
| 676 | "name": "tenant", | ||
| 677 | "type": "text", | ||
| 678 | "primaryKey": false, | ||
| 679 | "notNull": true, | ||
| 680 | "autoincrement": false | ||
| 681 | }, | ||
| 682 | "owner": { | ||
| 683 | "name": "owner", | ||
| 684 | "type": "text", | ||
| 685 | "primaryKey": false, | ||
| 686 | "notNull": true, | ||
| 687 | "autoincrement": false | ||
| 688 | }, | ||
| 689 | "subject": { | ||
| 690 | "name": "subject", | ||
| 691 | "type": "text", | ||
| 692 | "primaryKey": false, | ||
| 693 | "notNull": true, | ||
| 694 | "autoincrement": false | ||
| 695 | } | ||
| 696 | }, | ||
| 697 | "indexes": { | ||
| 698 | "plugin_storage_uidx": { | ||
| 699 | "name": "plugin_storage_uidx", | ||
| 700 | "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], | ||
| 701 | "isUnique": true | ||
| 702 | } | ||
| 703 | }, | ||
| 704 | "foreignKeys": {}, | ||
| 705 | "compositePrimaryKeys": {}, | ||
| 706 | "uniqueConstraints": {}, | ||
| 707 | "checkConstraints": {} | ||
| 708 | }, | ||
| 709 | "tool": { | ||
| 710 | "name": "tool", | ||
| 711 | "columns": { | ||
| 712 | "integration": { | ||
| 713 | "name": "integration", | ||
| 714 | "type": "text", | ||
| 715 | "primaryKey": false, | ||
| 716 | "notNull": true, | ||
| 717 | "autoincrement": false | ||
| 718 | }, | ||
| 719 | "connection": { | ||
| 720 | "name": "connection", | ||
| 721 | "type": "text", | ||
| 722 | "primaryKey": false, | ||
| 723 | "notNull": true, | ||
| 724 | "autoincrement": false | ||
| 725 | }, | ||
| 726 | "plugin_id": { | ||
| 727 | "name": "plugin_id", | ||
| 728 | "type": "text", | ||
| 729 | "primaryKey": false, | ||
| 730 | "notNull": true, | ||
| 731 | "autoincrement": false | ||
| 732 | }, | ||
| 733 | "name": { | ||
| 734 | "name": "name", | ||
| 735 | "type": "text", | ||
| 736 | "primaryKey": false, | ||
| 737 | "notNull": true, | ||
| 738 | "autoincrement": false | ||
| 739 | }, | ||
| 740 | "description": { | ||
| 741 | "name": "description", | ||
| 742 | "type": "text", | ||
| 743 | "primaryKey": false, | ||
| 744 | "notNull": true, | ||
| 745 | "autoincrement": false | ||
| 746 | }, | ||
| 747 | "input_schema": { | ||
| 748 | "name": "input_schema", | ||
| 749 | "type": "text", | ||
| 750 | "primaryKey": false, | ||
| 751 | "notNull": false, | ||
| 752 | "autoincrement": false | ||
| 753 | }, | ||
| 754 | "output_schema": { | ||
| 755 | "name": "output_schema", | ||
| 756 | "type": "text", | ||
| 757 | "primaryKey": false, | ||
| 758 | "notNull": false, | ||
| 759 | "autoincrement": false | ||
| 760 | }, | ||
| 761 | "annotations": { | ||
| 762 | "name": "annotations", | ||
| 763 | "type": "text", | ||
| 764 | "primaryKey": false, | ||
| 765 | "notNull": false, | ||
| 766 | "autoincrement": false | ||
| 767 | }, | ||
| 768 | "created_at": { | ||
| 769 | "name": "created_at", | ||
| 770 | "type": "integer", | ||
| 771 | "primaryKey": false, | ||
| 772 | "notNull": true, | ||
| 773 | "autoincrement": false | ||
| 774 | }, | ||
| 775 | "updated_at": { | ||
| 776 | "name": "updated_at", | ||
| 777 | "type": "integer", | ||
| 778 | "primaryKey": false, | ||
| 779 | "notNull": true, | ||
| 780 | "autoincrement": false | ||
| 781 | }, | ||
| 782 | "row_id": { | ||
| 783 | "name": "row_id", | ||
| 784 | "type": "text", | ||
| 785 | "primaryKey": true, | ||
| 786 | "notNull": true, | ||
| 787 | "autoincrement": false | ||
| 788 | }, | ||
| 789 | "tenant": { | ||
| 790 | "name": "tenant", | ||
| 791 | "type": "text", | ||
| 792 | "primaryKey": false, | ||
| 793 | "notNull": true, | ||
| 794 | "autoincrement": false | ||
| 795 | }, | ||
| 796 | "owner": { | ||
| 797 | "name": "owner", | ||
| 798 | "type": "text", | ||
| 799 | "primaryKey": false, | ||
| 800 | "notNull": true, | ||
| 801 | "autoincrement": false | ||
| 802 | }, | ||
| 803 | "subject": { | ||
| 804 | "name": "subject", | ||
| 805 | "type": "text", | ||
| 806 | "primaryKey": false, | ||
| 807 | "notNull": true, | ||
| 808 | "autoincrement": false | ||
| 809 | } | ||
| 810 | }, | ||
| 811 | "indexes": { | ||
| 812 | "tool_uidx": { | ||
| 813 | "name": "tool_uidx", | ||
| 814 | "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], | ||
| 815 | "isUnique": true | ||
| 816 | } | ||
| 817 | }, | ||
| 818 | "foreignKeys": {}, | ||
| 819 | "compositePrimaryKeys": {}, | ||
| 820 | "uniqueConstraints": {}, | ||
| 821 | "checkConstraints": {} | ||
| 822 | }, | ||
| 823 | "tool_policy": { | ||
| 824 | "name": "tool_policy", | ||
| 825 | "columns": { | ||
| 826 | "id": { | ||
| 827 | "name": "id", | ||
| 828 | "type": "text", | ||
| 829 | "primaryKey": false, | ||
| 830 | "notNull": true, | ||
| 831 | "autoincrement": false | ||
| 832 | }, | ||
| 833 | "pattern": { | ||
| 834 | "name": "pattern", | ||
| 835 | "type": "text", | ||
| 836 | "primaryKey": false, | ||
| 837 | "notNull": true, | ||
| 838 | "autoincrement": false | ||
| 839 | }, | ||
| 840 | "action": { | ||
| 841 | "name": "action", | ||
| 842 | "type": "text", | ||
| 843 | "primaryKey": false, | ||
| 844 | "notNull": true, | ||
| 845 | "autoincrement": false | ||
| 846 | }, | ||
| 847 | "position": { | ||
| 848 | "name": "position", | ||
| 849 | "type": "text", | ||
| 850 | "primaryKey": false, | ||
| 851 | "notNull": true, | ||
| 852 | "autoincrement": false | ||
| 853 | }, | ||
| 854 | "created_at": { | ||
| 855 | "name": "created_at", | ||
| 856 | "type": "integer", | ||
| 857 | "primaryKey": false, | ||
| 858 | "notNull": true, | ||
| 859 | "autoincrement": false | ||
| 860 | }, | ||
| 861 | "updated_at": { | ||
| 862 | "name": "updated_at", | ||
| 863 | "type": "integer", | ||
| 864 | "primaryKey": false, | ||
| 865 | "notNull": true, | ||
| 866 | "autoincrement": false | ||
| 867 | }, | ||
| 868 | "row_id": { | ||
| 869 | "name": "row_id", | ||
| 870 | "type": "text", | ||
| 871 | "primaryKey": true, | ||
| 872 | "notNull": true, | ||
| 873 | "autoincrement": false | ||
| 874 | }, | ||
| 875 | "tenant": { | ||
| 876 | "name": "tenant", | ||
| 877 | "type": "text", | ||
| 878 | "primaryKey": false, | ||
| 879 | "notNull": true, | ||
| 880 | "autoincrement": false | ||
| 881 | }, | ||
| 882 | "owner": { | ||
| 883 | "name": "owner", | ||
| 884 | "type": "text", | ||
| 885 | "primaryKey": false, | ||
| 886 | "notNull": true, | ||
| 887 | "autoincrement": false | ||
| 888 | }, | ||
| 889 | "subject": { | ||
| 890 | "name": "subject", | ||
| 891 | "type": "text", | ||
| 892 | "primaryKey": false, | ||
| 893 | "notNull": true, | ||
| 894 | "autoincrement": false | ||
| 895 | } | ||
| 896 | }, | ||
| 897 | "indexes": { | ||
| 898 | "tool_policy_uidx": { | ||
| 899 | "name": "tool_policy_uidx", | ||
| 900 | "columns": ["tenant", "owner", "subject", "id"], | ||
| 901 | "isUnique": true | ||
| 902 | } | ||
| 903 | }, | ||
| 904 | "foreignKeys": {}, | ||
| 905 | "compositePrimaryKeys": {}, | ||
| 906 | "uniqueConstraints": {}, | ||
| 907 | "checkConstraints": {} | ||
| 908 | } | ||
| 909 | }, | ||
| 910 | "views": {}, | ||
| 911 | "enums": {}, | ||
| 912 | "_meta": { | ||
| 913 | "schemas": {}, | ||
| 914 | "tables": {}, | ||
| 915 | "columns": {} | ||
| 916 | }, | ||
| 917 | "internal": { | ||
| 918 | "indexes": {} | ||
| 919 | } | ||
| 920 | } | ||
| @@ -22,6 +22,13 @@ · 21 unmodified lines | |||
| 22 | "when": 1782150869757, | 22 | "when": 1782150869757, |
| 23 | "tag": "0002_empty_rictor", | 23 | "tag": "0002_empty_rictor", |
| 24 | "breakpoints": true | 24 | "breakpoints": true |
| 25 | }, | ||
| 26 | { | ||
| 27 | "idx": 3, | ||
| 28 | "version": "6", | ||
| 29 | "when": 1783025153772, | ||
| 30 | "tag": "0003_graceful_the_fallen", | ||
| 31 | "breakpoints": true | ||
| 25 | } | 32 | } |
| 26 | ] | 33 | ] |
| 27 | } | 34 | } |
| @@ -5,7 +5,12 @@ · 4 unmodified lines | |||
| 5 | // renamed. | 5 | // renamed. |
| 6 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | ||
| 8 | import { Effect, sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk"; | 8 | import { |
| 9 | Effect, | ||
| 10 | oauthClientGcSqliteMigration, | ||
| 11 | sqliteDataMigration, | ||
| 12 | type SqliteDataMigration, | ||
| 13 | } from "@executor-js/sdk"; | ||
| 9 | import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; | 14 | import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; |
| 10 | import { | 15 | import { |
| 11 | openApiOutputSchemaDataMigration, | 16 | openApiOutputSchemaDataMigration, |
| @@ -37,4 +42,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 25 unmodified lines | |||
| 37 | openApiSpecBlobDataMigration, | 42 | openApiSpecBlobDataMigration, |
| 38 | graphqlIntrospectionBlobDataMigration, | 43 | graphqlIntrospectionBlobDataMigration, |
| 39 | googleOpenApiOwnershipDataMigration, | 44 | googleOpenApiOwnershipDataMigration, |
| 45 | // GC dead DCR oauth_client rows (old always-register duplicates) and backfill | ||
| 46 | // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C). | ||
| 47 | oauthClientGcSqliteMigration, | ||
| 40 | ]; | 48 | ]; |
| @@ -75,6 +75,7 @@ export const oauth_client = sqliteTable( · 74 unmodified lines | |||
| 75 | resource: text("resource"), | 75 | resource: text("resource"), |
| 76 | origin_kind: text("origin_kind"), | 76 | origin_kind: text("origin_kind"), |
| 77 | origin_integration: text("origin_integration"), | 77 | origin_integration: text("origin_integration"), |
| 78 | origin_issuer: text("origin_issuer"), | ||
| 78 | created_at: integer("created_at").notNull(), | 79 | created_at: integer("created_at").notNull(), |
| 79 | row_id: text("row_id").primaryKey().notNull(), | 80 | row_id: text("row_id").primaryKey().notNull(), |
| 80 | tenant: text("tenant").notNull(), | 81 | tenant: text("tenant").notNull(), |
| @@ -93,6 +93,12 @@ export const createSqliteFumaDb = async <const TTables extends FumaTables>( · 92 unmodified lines | |||
| 93 | ) { | 93 | ) { |
| 94 | await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_integration TEXT"); | 94 | await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_integration TEXT"); |
| 95 | } | 95 | } |
| 96 | if ( | ||
| 97 | oauthClientColumns.rows.length > 0 && | ||
| 98 | !oauthClientColumns.rows.some((column) => column["name"] === "origin_issuer") | ||
| 99 | ) { | ||
| 100 | await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_issuer TEXT"); | ||
| 101 | } | ||
| 96 | 102 | ||
| 97 | const { db, fuma } = createExecutorFumaDb(drizzleDb, { | 103 | const { db, fuma } = createExecutorFumaDb(drizzleDb, { |
| 98 | tables: options.tables, | 104 | tables: options.tables, |
| @@ -34,7 +34,7 @@ const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; · 33 unmodified lines | |||
| 34 | const EXECUTOR_GITHUB_URL = ( | 34 | const EXECUTOR_GITHUB_URL = ( |
| 35 | rootPackage.homepage ?? | 35 | rootPackage.homepage ?? |
| 36 | repositoryUrl ?? | 36 | repositoryUrl ?? |
| 37 | "https://github.com/RhysSullivan/executor" | 37 | "https://github.com/UsefulSoftwareCo/executor" |
| 38 | ) | 38 | ) |
| 39 | .replace(/^git\+/, "") | 39 | .replace(/^git\+/, "") |
| 40 | .replace(/\.git$/, ""); | 40 | .replace(/\.git$/, ""); |
| @@ -56,7 +56,7 @@ const { title, description, lastUpdated } = Astro.props; · 55 unmodified lines | |||
| 56 | <a href="/privacy" class="text-ink-faint transition-colors hover:text-ink">Privacy</a> | 56 | <a href="/privacy" class="text-ink-faint transition-colors hover:text-ink">Privacy</a> |
| 57 | <a href="/terms" class="text-ink-faint transition-colors hover:text-ink">Terms</a> | 57 | <a href="/terms" class="text-ink-faint transition-colors hover:text-ink">Terms</a> |
| 58 | <a | 58 | <a |
| 59 | href="https://github.com/RhysSullivan/executor" | 59 | href="https://github.com/UsefulSoftwareCo/executor" |
| 60 | class="text-ink-faint transition-colors hover:text-ink" | 60 | class="text-ink-faint transition-colors hover:text-ink" |
| 61 | > | 61 | > |
| 62 | GitHub | 62 | GitHub |
| @@ -21,7 +21,7 @@ const { · 20 unmodified lines | |||
| 21 | authorAvatar, | 21 | authorAvatar, |
| 22 | } = Astro.props; | 22 | } = Astro.props; |
| 23 | 23 | ||
| 24 | const GH = "https://github.com/RhysSullivan/executor"; | 24 | const GH = "https://github.com/UsefulSoftwareCo/executor"; |
| 25 | const authorX = `https://x.com/${authorHandle}`; | 25 | const authorX = `https://x.com/${authorHandle}`; |
| 26 | 26 | ||
| 27 | const formattedDate = date.toLocaleDateString("en-US", { | 27 | const formattedDate = date.toLocaleDateString("en-US", { |
| @@ -11,7 +11,7 @@ · 10 unmodified lines | |||
| 11 | // - resolve to null on any failure, in which case the nav simply drops the | 11 | // - resolve to null on any failure, in which case the nav simply drops the |
| 12 | // count and keeps the link. | 12 | // count and keeps the link. |
| 13 | 13 | ||
| 14 | const REPO = "RhysSullivan/executor"; | 14 | const REPO = "UsefulSoftwareCo/executor"; |
| 15 | 15 | ||
| 16 | export const GITHUB_REPO_URL = `https://github.com/${REPO}`; | 16 | export const GITHUB_REPO_URL = `https://github.com/${REPO}`; |
| 17 | 17 | ||
| @@ -3,7 +3,7 @@ export const prerender = true; · 2 unmodified lines | |||
| 3 | import Layout from "../../layouts/Layout.astro"; | 3 | import Layout from "../../layouts/Layout.astro"; |
| 4 | import { getCollection } from "astro:content"; | 4 | import { getCollection } from "astro:content"; |
| 5 | 5 | ||
| 6 | const GH = "https://github.com/RhysSullivan/executor"; | 6 | const GH = "https://github.com/UsefulSoftwareCo/executor"; |
| 7 | 7 | ||
| 8 | const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort( | 8 | const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort( |
| 9 | (a, b) => b.data.date.getTime() - a.data.date.getTime(), | 9 | (a, b) => b.data.date.getTime() - a.data.date.getTime(), |
| @@ -8,7 +8,7 @@ import { SelfHostContactModal } from "../components/self-host-contact-modal"; · 7 unmodified lines | |||
| 8 | import ycBackedBy from "../assets/yc-backed-by.svg?url"; | 8 | import ycBackedBy from "../assets/yc-backed-by.svg?url"; |
| 9 | import { getStars, formatStars } from "../lib/github"; | 9 | import { getStars, formatStars } from "../lib/github"; |
| 10 | 10 | ||
| 11 | const GH = "https://github.com/RhysSullivan/executor"; | 11 | const GH = "https://github.com/UsefulSoftwareCo/executor"; |
| 12 | // Cloud sign-in. Same origin in prod: the cloud Worker owns executor.sh and | 12 | // Cloud sign-in. Same origin in prod: the cloud Worker owns executor.sh and |
| 13 | // /api/* is app-owned (apps/cloud/src/app-paths.ts), so this hits the WorkOS | 13 | // /api/* is app-owned (apps/cloud/src/app-paths.ts), so this hits the WorkOS |
| 14 | // login flow directly. Returning users land here; new users use Get started. | 14 | // login flow directly. Returning users land here; new users use Get started. |
| @@ -50,7 +50,7 @@ Once you know which form I want: · 35 unmodified lines | |||
| 50 | 3. Once the tools are available, help me add my first integration and get one tool working end to end. | 50 | 3. Once the tools are available, help me add my first integration and get one tool working end to end. |
| 51 | 51 | ||
| 52 | Docs: https://executor.sh/docs | 52 | Docs: https://executor.sh/docs |
| 53 | Source (and the place to start if something breaks): https://github.com/RhysSullivan/executor`; | 53 | Source (and the place to start if something breaks): https://github.com/UsefulSoftwareCo/executor`; |
| 54 | --- | 54 | --- |
| 55 | 55 | ||
| 56 | <Layout> | 56 | <Layout> |
| @@ -344,7 +344,7 @@ · 343 unmodified lines | |||
| 344 | "version": "0.0.24", | 344 | "version": "0.0.24", |
| 345 | "dependencies": { | 345 | "dependencies": { |
| 346 | "@executor-js/api": "workspace:*", | 346 | "@executor-js/api": "workspace:*", |
| 347 | "@executor-js/emulate": "^0.10.0", | 347 | "@executor-js/emulate": "^0.13.2", |
| 348 | "@executor-js/mcporter": "^0.11.4", | 348 | "@executor-js/mcporter": "^0.11.4", |
| 349 | "@executor-js/plugin-google": "workspace:*", | 349 | "@executor-js/plugin-google": "workspace:*", |
| 350 | "@executor-js/plugin-graphql": "workspace:*", | 350 | "@executor-js/plugin-graphql": "workspace:*", |
| @@ -583,6 +583,7 @@ · 232 unmodified lines | |||
| 583 | "@standard-schema/spec": "^1.1.0", | 583 | "@standard-schema/spec": "^1.1.0", |
| 584 | "fractional-indexing": "^3.2.0", | 584 | "fractional-indexing": "^3.2.0", |
| 585 | "oauth4webapi": "^3.8.5", | 585 | "oauth4webapi": "^3.8.5", |
| 586 | "tldts": "^7.0.28", | ||
| 586 | }, | 587 | }, |
| 587 | "devDependencies": { | 588 | "devDependencies": { |
| 588 | "@effect/atom-react": "catalog:", | 589 | "@effect/atom-react": "catalog:", |
| @@ -665,6 +666,7 @@ · 76 unmodified lines | |||
| 665 | "@types/node": "catalog:", | 666 | "@types/node": "catalog:", |
| 666 | "bun-types": "catalog:", | 667 | "bun-types": "catalog:", |
| 667 | "typescript": "catalog:", | 668 | "typescript": "catalog:", |
| 669 | "urlpattern-polyfill": "^10.1.0", | ||
| 668 | "vitest": "catalog:", | 670 | "vitest": "catalog:", |
| 669 | }, | 671 | }, |
| 670 | }, | 672 | }, |
| @@ -1743,7 +1745,7 @@ · 1072 unmodified lines | |||
| 1743 | 1745 | ||
| 1744 | "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], | 1746 | "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], |
| 1745 | 1747 | ||
| 1746 | "@executor-js/emulate": ["@executor-js/emulate@0.10.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-GE1+XDQ4FJt4ZDrwNjuUqUEG1WaH06UE12ME/xJdcCNbsa6EE6SA+i8onVJQ5Dr7DbUveU37E4djLTiAcceLPw=="], | 1748 | "@executor-js/emulate": ["@executor-js/emulate@0.13.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-ikmzZgBrRnmJgKKOW9TE2KCCdenKtihx2nWW/mMMmugn3UlVJsxuIxp6jv0BEo4GZcY8Ve2oka6UEKl+gBKRZg=="], |
| 1747 | 1749 | ||
| 1748 | "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], | 1750 | "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], |
| 1749 | 1751 | ||
| @@ -0,0 +1,176 @@ | |||
| 1 | // Cloud: the DCR root-domain collision, end to end over the product API. | ||
| 2 | // | ||
| 3 | // The bug this guards (issue #1120): two DIFFERENT integrations can share one | ||
| 4 | // registrable root domain but differ by host, e.g. a "Cloudflare" MCP | ||
| 5 | // integration whose OAuth lives on mcp.cloudflare.com vs the plain "Cloudflare" | ||
| 6 | // REST integration on api.cloudflare.com. When the MCP integration is connected | ||
| 7 | // it Dynamic-Client-Registers an oauth_client automatically. That auto-minted | ||
| 8 | // client must NEVER leak into the REST integration's Add-connection app picker | ||
| 9 | // (it is plumbing, not an app the user picked), and connecting the MCP | ||
| 10 | // integration a second time must REUSE the same client rather than mint a | ||
| 11 | // "name 2" duplicate. | ||
| 12 | // | ||
| 13 | // Everything here is over the wire: the typed product client drives the real | ||
| 14 | // cloud API, and a real authorization server (with an RFC 7591 registration | ||
| 15 | // endpoint) runs inside the scenario on 127.0.0.1. To reproduce the collision | ||
| 16 | // the scenario stores mcp.cloudflare.com / api.cloudflare.com as the clients' | ||
| 17 | // endpoint URLs (the classifier keys off those strings, never the network) | ||
| 18 | // while pointing the actual registration round-trip at the local server. | ||
| 19 | // | ||
| 20 | // This e2e proves the SERVER-SIDE half over the wire: (b) DCR mints a client | ||
| 21 | // against the MCP host, (d) reconnecting reuses it with no duplicate row, and | ||
| 22 | // the REAL `listClients` projection marks that client as | ||
| 23 | // `dynamic_client_registration` while preserving its mcp.cloudflare.com | ||
| 24 | // endpoints. Those two facts (origin kind + persisted host) are the entire | ||
| 25 | // input the picker classifier keys off. The picker's own verdict for the REST | ||
| 26 | // integration (that the DCR client is offered in no tier) is asserted against | ||
| 27 | // the exact production classifier in the React package's integration test | ||
| 28 | // (packages/react/src/plugins/dcr-root-domain-isolation.test.ts); it lives | ||
| 29 | // there because that pure function ships in @executor-js/react, and the e2e | ||
| 30 | // package deliberately does not depend on the React layer. See the report. | ||
| 31 | import { randomBytes } from "node:crypto"; | ||
| 32 | |||
| 33 | import { expect } from "@effect/vitest"; | ||
| 34 | import { Effect } from "effect"; | ||
| 35 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 36 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 37 | import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; | ||
| 38 | import { serveOAuthTestServer } from "@executor-js/sdk/testing"; | ||
| 39 | |||
| 40 | import { scenario } from "../src/scenario"; | ||
| 41 | import { Api, Target } from "../src/services"; | ||
| 42 | |||
| 43 | const api = composePluginApi([openApiHttpPlugin()] as const); | ||
| 44 | |||
| 45 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
| 46 | |||
| 47 | // A same-provider, different-host pair: same registrable root (cloudflare.com), | ||
| 48 | // distinct hosts. These are the endpoint URLs the clients are PERSISTED with, | ||
| 49 | // which is all the classifier looks at. | ||
| 50 | const MCP_ISSUER = "https://mcp.cloudflare.com"; | ||
| 51 | const MCP_AUTHORIZE = "https://mcp.cloudflare.com/authorize"; | ||
| 52 | const MCP_TOKEN = "https://mcp.cloudflare.com/token"; | ||
| 53 | const REST_AUTHORIZE = "https://api.cloudflare.com/client/v4/oauth/authorize"; | ||
| 54 | const REST_TOKEN = "https://api.cloudflare.com/client/v4/oauth/token"; | ||
| 55 | |||
| 56 | scenario( | ||
| 57 | "OAuth DCR · an MCP integration's auto-registered client never leaks into a same-root REST integration's app picker, and reconnecting reuses it", | ||
| 58 | {}, | ||
| 59 | Effect.scoped( | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const target = yield* Target; | ||
| 62 | const { client: makeClient } = yield* Api; | ||
| 63 | // A real authorization server with an RFC 7591 registration endpoint. Its | ||
| 64 | // /register mints a client_id; the mcp.cloudflare.com URLs above are what | ||
| 65 | // we persist so the classifier sees the real-world collision shape. | ||
| 66 | const oauth = yield* serveOAuthTestServer(); | ||
| 67 | const identity = yield* target.newIdentity(); | ||
| 68 | const client = yield* makeClient(api, identity); | ||
| 69 | |||
| 70 | // Two integrations on the SAME provider root, different hosts: an MCP-style | ||
| 71 | // one (mcp.cloudflare.com) and a plain REST one (api.cloudflare.com). | ||
| 72 | const mcpIntegration = IntegrationSlug.make(unique("cloudflare_mcp")); | ||
| 73 | const restIntegration = IntegrationSlug.make(unique("cloudflare_api")); | ||
| 74 | |||
| 75 | // The REST integration declares its OAuth endpoints up front (api.cloudflare.com). | ||
| 76 | yield* client.openapi.addSpec({ | ||
| 77 | payload: { | ||
| 78 | spec: { | ||
| 79 | kind: "blob", | ||
| 80 | value: JSON.stringify({ | ||
| 81 | openapi: "3.0.3", | ||
| 82 | info: { title: "Cloudflare REST API", version: "1.0.0" }, | ||
| 83 | paths: { | ||
| 84 | "/zones": { | ||
| 85 | get: { | ||
| 86 | operationId: "listZones", | ||
| 87 | tags: ["default"], | ||
| 88 | responses: { "200": { description: "zones" } }, | ||
| 89 | }, | ||
| 90 | }, | ||
| 91 | }, | ||
| 92 | }), | ||
| 93 | }, | ||
| 94 | slug: restIntegration, | ||
| 95 | baseUrl: "https://api.cloudflare.com/client/v4", | ||
| 96 | authenticationTemplate: [ | ||
| 97 | { | ||
| 98 | slug: "oauth", | ||
| 99 | kind: "oauth2", | ||
| 100 | authorizationUrl: REST_AUTHORIZE, | ||
| 101 | tokenUrl: REST_TOKEN, | ||
| 102 | scopes: ["read"], | ||
| 103 | }, | ||
| 104 | ], | ||
| 105 | }, | ||
| 106 | }); | ||
| 107 | |||
| 108 | // (b) Connect the MCP-style integration via Dynamic Client Registration. | ||
| 109 | // The registration round-trip hits the real local server; the persisted | ||
| 110 | // client carries the mcp.cloudflare.com endpoints and its issuer. | ||
| 111 | const registerMcp = () => | ||
| 112 | client.oauth.registerDynamic({ | ||
| 113 | payload: { | ||
| 114 | owner: "org", | ||
| 115 | // Passed but overridden by the server's deterministic dcr-<host> slug | ||
| 116 | // when an issuer is present; kept as a fallback for the no-issuer path. | ||
| 117 | slug: OAuthClientSlug.make(unique("cloudflare-mcp")), | ||
| 118 | issuer: MCP_ISSUER, | ||
| 119 | registrationEndpoint: oauth.registrationEndpoint, | ||
| 120 | authorizationUrl: MCP_AUTHORIZE, | ||
| 121 | tokenUrl: MCP_TOKEN, | ||
| 122 | scopes: ["read"], | ||
| 123 | clientName: "Cloudflare MCP", | ||
| 124 | originIntegration: mcpIntegration, | ||
| 125 | }, | ||
| 126 | }); | ||
| 127 | |||
| 128 | const first = yield* registerMcp(); | ||
| 129 | expect( | ||
| 130 | String(first.client), | ||
| 131 | "the auto-registered client gets a deterministic issuer-host slug", | ||
| 132 | ).toBe("dcr-mcp-cloudflare-com"); | ||
| 133 | |||
| 134 | // (d) Reconnect the MCP integration: the SAME authorization server (issuer) | ||
| 135 | // must reuse the existing client, not mint a duplicate "…-2". | ||
| 136 | const second = yield* registerMcp(); | ||
| 137 | expect( | ||
| 138 | String(second.client), | ||
| 139 | "reconnecting the MCP integration reuses the same DCR client (no duplicate row)", | ||
| 140 | ).toBe(String(first.client)); | ||
| 141 | |||
| 142 | // The REAL API projection of every client the owner can see. This is the | ||
| 143 | // exact shape the picker classifier consumes. | ||
| 144 | const clients = yield* client.oauth.listClients(); | ||
| 145 | const dcrRows = clients.filter( | ||
| 146 | (entry) => entry.origin.kind === "dynamic_client_registration", | ||
| 147 | ); | ||
| 148 | expect( | ||
| 149 | dcrRows.map((entry) => String(entry.slug)), | ||
| 150 | "exactly one DCR client exists for the shared authorization server (no duplicate)", | ||
| 151 | ).toEqual(["dcr-mcp-cloudflare-com"]); | ||
| 152 | |||
| 153 | const dcrRow = dcrRows[0]!; | ||
| 154 | // The picker excludes a client iff its origin is DCR — assert the API | ||
| 155 | // projects that origin so the classifier will hide it. | ||
| 156 | expect( | ||
| 157 | dcrRow.origin.kind, | ||
| 158 | "the auto-registered client is projected as a DCR client (the flag the picker hides on)", | ||
| 159 | ).toBe("dynamic_client_registration"); | ||
| 160 | // The persisted endpoints are the MCP host, NOT the local registration | ||
| 161 | // server: this is the same-root/different-host collision the classifier | ||
| 162 | // must not silently promote into the REST integration's picker. | ||
| 163 | expect( | ||
| 164 | dcrRow.tokenUrl, | ||
| 165 | "the DCR client keeps the MCP host it was registered for, not the registration endpoint", | ||
| 166 | ).toBe(MCP_TOKEN); | ||
| 167 | // Sanity: the REST integration itself has NO manual app, so its picker | ||
| 168 | // would fall back to near/other tiers if the DCR client were ever | ||
| 169 | // classified as a real app — the React test proves it never is. | ||
| 170 | expect( | ||
| 171 | clients.some((entry) => entry.origin.kind === "manual"), | ||
| 172 | "no manual OAuth app was registered in this scenario", | ||
| 173 | ).toBe(false); | ||
| 174 | }), | ||
| 175 | ), | ||
| 176 | ); | ||
| @@ -0,0 +1,200 @@ | |||
| 1 | // Cloud-only (billing): the pre-execution guards that sit in front of the MCP | ||
| 2 | // session Durable Object's execution engine. Two independent gates run before | ||
| 3 | // every new `execute`: | ||
| 4 | // | ||
| 5 | // - the BALANCE gate consults Autumn's `balances.check` and blocks once an | ||
| 6 | // org has used its plan's included executions, and | ||
| 7 | // - the RATE-LIMIT backstop counts executions per org per hour and blocks | ||
| 8 | // runaway automation even when billing is down. | ||
| 9 | // | ||
| 10 | // Both FAIL OPEN: an Autumn error or timeout must never take executions down | ||
| 11 | // with the billing provider. These scenarios pin all four behaviours end to | ||
| 12 | // end against the real workerd + McpSessionDO topology, driving a real MCP | ||
| 13 | // client and reading Autumn's own ledger as ground truth — a blocked execution | ||
| 14 | // must not reach the meter, and a failed-open one must show the check was | ||
| 15 | // actually attempted (and faulted) rather than silently skipped. | ||
| 16 | import { expect } from "@effect/vitest"; | ||
| 17 | import { Effect } from "effect"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | EXECUTION_LIMIT_BLOCKED_MESSAGE, | ||
| 21 | RATE_LIMIT_BLOCKED_MESSAGE, | ||
| 22 | } from "../../apps/cloud/src/engine/execution-limit-messages"; | ||
| 23 | import { scenario } from "../src/scenario"; | ||
| 24 | import { Autumn, Billing, Mcp, Target } from "../src/services"; | ||
| 25 | import { E2E_EXECUTION_RATE_LIMIT } from "../setup/execution-limits"; | ||
| 26 | import type { Identity } from "../src/target"; | ||
| 27 | |||
| 28 | const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; | ||
| 29 | |||
| 30 | /** The org the bearer is scoped to — the Autumn customer id the guards decide | ||
| 31 | * against — read from the JWT's public claims. */ | ||
| 32 | const orgIdOf = (bearer: string): string => { | ||
| 33 | const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as { | ||
| 34 | readonly org_id?: string; | ||
| 35 | }; | ||
| 36 | if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim"); | ||
| 37 | return claims.org_id; | ||
| 38 | }; | ||
| 39 | |||
| 40 | // The e2e worker is booted with a small EXECUTION_RATE_LIMIT_PER_HOUR so the | ||
| 41 | // backstop is reachable with real executions (cloud.boot.ts). The prod cap of | ||
| 42 | // 1000/hour can't be. One shared constant (setup/execution-limits.ts) keeps | ||
| 43 | // this and the boot env from drifting apart. | ||
| 44 | const RATE_LIMIT = E2E_EXECUTION_RATE_LIMIT; | ||
| 45 | |||
| 46 | scenario( | ||
| 47 | "Billing · an org out of executions is blocked before the code ever runs", | ||
| 48 | { timeout: 180_000 }, | ||
| 49 | Effect.gen(function* () { | ||
| 50 | // Gates: billing is enforced here AND the Autumn ledger is observable. | ||
| 51 | // Yield before any work so a target missing either capability skips cleanly. | ||
| 52 | yield* Billing; | ||
| 53 | const autumn = yield* Autumn; | ||
| 54 | const target = yield* Target; | ||
| 55 | const mcp = yield* Mcp; | ||
| 56 | |||
| 57 | const identity = yield* target.newIdentity(); | ||
| 58 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 59 | const customerId = orgIdOf(bearer); | ||
| 60 | |||
| 61 | // Burn the whole included allotment so the org's remaining balance is zero. | ||
| 62 | // This records one `balances.track` event (the plan's included amount) — | ||
| 63 | // the baseline the blocked execution is measured against below. | ||
| 64 | yield* autumn.exhaustExecutions(customerId); | ||
| 65 | const afterExhaust = yield* autumn.usageEvents({ customerId, featureId: "executions" }); | ||
| 66 | expect(afterExhaust.length, "exhausting the balance records exactly one usage event").toBe(1); | ||
| 67 | |||
| 68 | // A NEW MCP session => a fresh session DO => a fresh gate cache, so the | ||
| 69 | // balance is checked live (no 60s stale-allow window from a prior session). | ||
| 70 | const session = mcp.session(identity); | ||
| 71 | const result = yield* session.call("execute", { code: "return 1 + 1;" }); | ||
| 72 | |||
| 73 | expect(result.ok, "a blocked execution surfaces as an MCP error result").toBe(false); | ||
| 74 | expect(result.text, "the client sees the execution-limit message").toContain( | ||
| 75 | EXECUTION_LIMIT_BLOCKED_MESSAGE, | ||
| 76 | ); | ||
| 77 | |||
| 78 | // The meter is the source of truth: a blocked execution is neither run nor | ||
| 79 | // usage-tracked. Give any (erroneous) fire-and-forget track ample time to | ||
| 80 | // land, then assert nothing beyond the exhaust event was recorded. | ||
| 81 | const settle = yield* autumn.usageEvents({ customerId, featureId: "executions" }); | ||
| 82 | expect(settle.length, "the blocked execution adds no 'executions' usage event").toBe( | ||
| 83 | afterExhaust.length, | ||
| 84 | ); | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | |||
| 88 | scenario( | ||
| 89 | "Billing · a balance-check failure fails open and still runs the execution", | ||
| 90 | { timeout: 180_000 }, | ||
| 91 | Effect.gen(function* () { | ||
| 92 | yield* Billing; | ||
| 93 | const autumn = yield* Autumn; | ||
| 94 | const target = yield* Target; | ||
| 95 | const mcp = yield* Mcp; | ||
| 96 | |||
| 97 | const identity = yield* target.newIdentity(); | ||
| 98 | |||
| 99 | yield* Effect.gen(function* () { | ||
| 100 | // One-shot 500 on the very next balances.check the gate makes. | ||
| 101 | yield* autumn.armFault({ | ||
| 102 | match: { operationId: "balances.check" }, | ||
| 103 | response: { status: 500, body: { message: "emulated billing outage" } }, | ||
| 104 | times: 1, | ||
| 105 | }); | ||
| 106 | |||
| 107 | const session = mcp.session(identity); | ||
| 108 | const result = yield* session.call("execute", { code: "return 6 * 7;" }); | ||
| 109 | |||
| 110 | // Fail open: the outage must not block a solvent org's execution. | ||
| 111 | expect(result.ok, "the execution succeeds despite the billing outage").toBe(true); | ||
| 112 | expect(result.text, "it returns its value").toContain("42"); | ||
| 113 | |||
| 114 | // Proof the gate actually CONSULTED Autumn and failed open (rather than | ||
| 115 | // never checking): the faulted balances.check is in the emulator ledger. | ||
| 116 | const checks = yield* autumn.ledgerFor("balances.check"); | ||
| 117 | expect( | ||
| 118 | checks.some((entry) => entry.faulted), | ||
| 119 | "the gate's balances.check reached Autumn and was faulted", | ||
| 120 | ).toBe(true); | ||
| 121 | }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); | ||
| 122 | }), | ||
| 123 | ); | ||
| 124 | |||
| 125 | scenario( | ||
| 126 | "Billing · a balance-check timeout fails open and still runs the execution", | ||
| 127 | { timeout: 180_000 }, | ||
| 128 | Effect.gen(function* () { | ||
| 129 | yield* Billing; | ||
| 130 | const autumn = yield* Autumn; | ||
| 131 | const target = yield* Target; | ||
| 132 | const mcp = yield* Mcp; | ||
| 133 | |||
| 134 | const identity = yield* target.newIdentity(); | ||
| 135 | |||
| 136 | yield* Effect.gen(function* () { | ||
| 137 | // The armed check stalls 3s before returning — past the gate's 2s budget, | ||
| 138 | // so the gate times out client-side and fails open. (The response still | ||
| 139 | // arrives after the delay; the ~2s added latency fits the timeout.) | ||
| 140 | yield* autumn.armFault({ | ||
| 141 | match: { operationId: "balances.check" }, | ||
| 142 | response: { status: 200, body: { allowed: true } }, | ||
| 143 | times: 1, | ||
| 144 | delayMs: 3000, | ||
| 145 | }); | ||
| 146 | |||
| 147 | const session = mcp.session(identity); | ||
| 148 | const result = yield* session.call("execute", { code: "return 21 * 2;" }); | ||
| 149 | |||
| 150 | expect(result.ok, "the execution succeeds despite the slow balance check").toBe(true); | ||
| 151 | expect(result.text, "it returns its value").toContain("42"); | ||
| 152 | |||
| 153 | const checks = yield* autumn.ledgerFor("balances.check"); | ||
| 154 | expect(checks.length, "the gate did attempt the (stalled) balances.check").toBeGreaterThan(0); | ||
| 155 | }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | |||
| 159 | scenario( | ||
| 160 | "Billing · the rate-limit backstop blocks runaway executions and doesn't meter them", | ||
| 161 | { timeout: 180_000 }, | ||
| 162 | Effect.gen(function* () { | ||
| 163 | yield* Billing; | ||
| 164 | const autumn = yield* Autumn; | ||
| 165 | const target = yield* Target; | ||
| 166 | const mcp = yield* Mcp; | ||
| 167 | |||
| 168 | const identity = yield* target.newIdentity(); | ||
| 169 | const bearer = yield* mcp.mintBearer(emailOf(identity)); | ||
| 170 | const customerId = orgIdOf(bearer); | ||
| 171 | |||
| 172 | // All executions run in ONE session so the balance cache stays allowed | ||
| 173 | // (the org is solvent) — only the per-org hourly counter should trip. | ||
| 174 | const session = mcp.session(identity); | ||
| 175 | |||
| 176 | // Run exactly the limit's worth of allowed executions. | ||
| 177 | for (let i = 1; i <= RATE_LIMIT; i++) { | ||
| 178 | const ok = yield* session.call("execute", { code: `return ${i};` }); | ||
| 179 | expect(ok.ok, `execution ${i} (within the limit) succeeds`).toBe(true); | ||
| 180 | } | ||
| 181 | |||
| 182 | // The next one crosses the hourly cap: blocked with the backstop message. | ||
| 183 | const blocked = yield* session.call("execute", { code: "return 999;" }); | ||
| 184 | expect(blocked.ok, "the execution past the cap is an MCP error result").toBe(false); | ||
| 185 | expect(blocked.text, "the client sees the rate-limit message").toContain( | ||
| 186 | RATE_LIMIT_BLOCKED_MESSAGE, | ||
| 187 | ); | ||
| 188 | |||
| 189 | // A rate-limited execution is neither run nor metered: exactly the allowed | ||
| 190 | // runs reached the Autumn meter, and the blocked one added nothing. | ||
| 191 | const metered = yield* autumn.expectUsage({ | ||
| 192 | customerId, | ||
| 193 | featureId: "executions", | ||
| 194 | count: RATE_LIMIT, | ||
| 195 | }); | ||
| 196 | expect(metered.length, "only the allowed executions are metered — the blocked one is not").toBe( | ||
| 197 | RATE_LIMIT, | ||
| 198 | ); | ||
| 199 | }), | ||
| 200 | ); | ||
| @@ -22,7 +22,7 @@ · 21 unmodified lines | |||
| 22 | }, | 22 | }, |
| 23 | "dependencies": { | 23 | "dependencies": { |
| 24 | "@executor-js/api": "workspace:*", | 24 | "@executor-js/api": "workspace:*", |
| 25 | "@executor-js/emulate": "^0.10.0", | 25 | "@executor-js/emulate": "^0.13.2", |
| 26 | "@executor-js/mcporter": "^0.11.4", | 26 | "@executor-js/mcporter": "^0.11.4", |
| 27 | "@executor-js/plugin-google": "workspace:*", | 27 | "@executor-js/plugin-google": "workspace:*", |
| 28 | "@executor-js/plugin-graphql": "workspace:*", | 28 | "@executor-js/plugin-graphql": "workspace:*", |
| @@ -49,3 +49,21 @@ const toSeed = (plan: AtmnPlan): AutumnSeedPlan => ({ · 48 unmodified lines | |||
| 49 | export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) => | 49 | export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) => |
| 50 | toSeed(p as AtmnPlan), | 50 | toSeed(p as AtmnPlan), |
| 51 | ); | 51 | ); |
| 52 | |||
| 53 | /** | ||
| 54 | * The "executions" allotment a fresh org has on the auto-enabled default plan | ||
| 55 | * (Free): the exact number of executions it takes to exhaust the quota. Read | ||
| 56 | * from the same seed the emulator is booted with, so a plan change in | ||
| 57 | * autumn.config.ts flows straight into the balance-gate scenarios rather than | ||
| 58 | * drifting against a hardcoded magic number. | ||
| 59 | */ | ||
| 60 | export const DEFAULT_PLAN_EXECUTIONS_INCLUDED: number = (() => { | ||
| 61 | const defaultPlan = AUTUMN_PLAN_SEED.find((plan) => plan.auto_enable); | ||
| 62 | const executions = defaultPlan?.items.find((item) => item.feature_id === "executions"); | ||
| 63 | if (executions?.included === undefined) { | ||
| 64 | throw new Error( | ||
| 65 | "autumn-plans: the auto-enabled default plan has no included 'executions' amount to exhaust", | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | return executions.included; | ||
| 69 | })(); | ||
| @@ -12,6 +12,7 @@ import { createEmulator } from "@executor-js/emulate"; · 11 unmodified lines | |||
| 12 | 12 | ||
| 13 | import { bootProcesses, waitForHttp } from "./boot"; | 13 | import { bootProcesses, waitForHttp } from "./boot"; |
| 14 | import { AUTUMN_PLAN_SEED } from "./autumn-plans"; | 14 | import { AUTUMN_PLAN_SEED } from "./autumn-plans"; |
| 15 | import { E2E_EXECUTION_RATE_LIMIT } from "./execution-limits"; | ||
| 15 | 16 | ||
| 16 | export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); | 17 | export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); |
| 17 | 18 | ||
| @@ -90,6 +91,12 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 72 unmodified lines | |||
| 90 | MCP_AUTHKIT_DOMAIN: workosUrl, | 91 | MCP_AUTHKIT_DOMAIN: workosUrl, |
| 91 | MCP_RESOURCE_ORIGIN: options.publicUrl, | 92 | MCP_RESOURCE_ORIGIN: options.publicUrl, |
| 92 | ALLOW_LOCAL_NETWORK: "true", | 93 | ALLOW_LOCAL_NETWORK: "true", |
| 94 | // Shrink the per-org hourly execution cap (prod default 1000) to a number | ||
| 95 | // the rate-limit-backstop scenario can actually exhaust with real | ||
| 96 | // executions — but see execution-limits.ts: it must stay above every other | ||
| 97 | // scenario's per-org execute count. Reaches the worker via | ||
| 98 | // CLOUDFLARE_INCLUDE_PROCESS_ENV, same as ALLOW_LOCAL_NETWORK. | ||
| 99 | EXECUTION_RATE_LIMIT_PER_HOUR: String(E2E_EXECUTION_RATE_LIMIT), | ||
| 93 | // Throwaway PGlite on its own port + dir so it never fights `bun dev`. | 100 | // Throwaway PGlite on its own port + dir so it never fights `bun dev`. |
| 94 | DEV_DB_PORT: String(options.dbPort), | 101 | DEV_DB_PORT: String(options.dbPort), |
| 95 | DEV_DB_PATH: dbPath, | 102 | DEV_DB_PATH: dbPath, |
| @@ -0,0 +1,14 @@ | |||
| 1 | // The e2e worker's per-org hourly execution cap (EXECUTION_RATE_LIMIT_PER_HOUR). | ||
| 2 | // One constant with two consumers, the boot recipe env (cloud.boot.ts) and the | ||
| 3 | // rate-limit backstop scenario (cloud/mcp-execution-limits.test.ts), so they | ||
| 4 | // cannot drift apart. | ||
| 5 | // | ||
| 6 | // Picking the value is a squeeze from both sides. It must be LOW enough that | ||
| 7 | // the backstop scenario can exhaust it with real sequential executions (prod's | ||
| 8 | // 1000/hour cannot be reached in a test), and HIGH enough that no other | ||
| 9 | // scenario trips it: the counter is per organization and every `execute` a | ||
| 10 | // scenario runs counts against its org, so this must exceed the busiest | ||
| 11 | // single-org scenario's execute count (currently toolkits-mcp at ~8) with | ||
| 12 | // comfortable headroom. If a scenario ever fails with the rate-limit backstop | ||
| 13 | // message, it outgrew this cap: raise it here, never in the boot env alone. | ||
| 14 | export const E2E_EXECUTION_RATE_LIMIT = 20; | ||
| @@ -9,6 +9,8 @@ · 8 unmodified lines | |||
| 9 | // so the contract has to be pinned where the usage is read. | 9 | // so the contract has to be pinned where the usage is read. |
| 10 | import { Effect, Schedule } from "effect"; | 10 | import { Effect, Schedule } from "effect"; |
| 11 | 11 | ||
| 12 | import { DEFAULT_PLAN_EXECUTIONS_INCLUDED } from "../../setup/autumn-plans"; | ||
| 13 | |||
| 12 | /** One usage event Autumn recorded: a `track` of `value` units of `featureId` | 14 | /** One usage event Autumn recorded: a `track` of `value` units of `featureId` |
| 13 | * for `customerId` (the organization the execution ran under). */ | 15 | * for `customerId` (the organization the execution ran under). */ |
| 14 | export interface UsageEvent { | 16 | export interface UsageEvent { |
| @@ -24,6 +26,33 @@ export interface UsageQuery { · 9 unmodified lines | |||
| 24 | readonly featureId: string; | 26 | readonly featureId: string; |
| 25 | } | 27 | } |
| 26 | 28 | ||
| 29 | /** A one-shot (or counted) fault to arm against a matching Autumn request. Mirrors | ||
| 30 | * the emulator's `POST /_emulate/faults` body: a request matching every provided | ||
| 31 | * `match` criterion short-circuits with `response`, `times` decrements per hit | ||
| 32 | * (default 1, removed at zero), and `delayMs` stalls before the fault returns — | ||
| 33 | * the knob a timeout scenario uses to push Autumn past the gate's time budget. */ | ||
| 34 | export interface FaultInput { | ||
| 35 | readonly match: { | ||
| 36 | readonly operationId?: string; | ||
| 37 | readonly method?: string; | ||
| 38 | readonly pathPattern?: string; | ||
| 39 | }; | ||
| 40 | readonly response: { readonly status: number; readonly body?: unknown }; | ||
| 41 | readonly times?: number; | ||
| 42 | readonly delayMs?: number; | ||
| 43 | } | ||
| 44 | |||
| 45 | /** One request the emulator recorded in its ledger, narrowed to what these | ||
| 46 | * scenarios assert on: which operation ran and whether a fault short-circuited | ||
| 47 | * it. A faulted `balances.check` entry is the proof the gate actually consulted | ||
| 48 | * Autumn (and then failed open) rather than never checking at all. */ | ||
| 49 | export interface LedgerEntry { | ||
| 50 | readonly operationId?: string; | ||
| 51 | readonly method: string; | ||
| 52 | readonly path: string; | ||
| 53 | readonly faulted: boolean; | ||
| 54 | } | ||
| 55 | |||
| 27 | export interface AutumnSurface { | 56 | export interface AutumnSurface { |
| 28 | /** One-shot read of the matching usage events from the ledger. */ | 57 | /** One-shot read of the matching usage events from the ledger. */ |
| 29 | readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>; | 58 | readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>; |
| @@ -41,6 +70,20 @@ export interface AutumnSurface { · 11 unmodified lines | |||
| 41 | * the billing backend becomes consistent. The `sessionId` is the last path | 70 | * the billing backend becomes consistent. The `sessionId` is the last path |
| 42 | * segment of the hosted checkout URL the browser was sent to. */ | 71 | * segment of the hosted checkout URL the browser was sent to. */ |
| 43 | readonly settleCheckout: (sessionId: string) => Effect.Effect<void, unknown>; | 72 | readonly settleCheckout: (sessionId: string) => Effect.Effect<void, unknown>; |
| 73 | /** Burn an org's entire remaining "executions" balance in one `balances.track`, | ||
| 74 | * so the next `balances.check` reports `allowed: false`. The amount is the | ||
| 75 | * default plan's included allotment (read from the plan seed, never hardcoded), | ||
| 76 | * which drives `remaining` to zero. This is the setup a "blocked at cap" | ||
| 77 | * scenario runs before opening the session it expects to be gated. */ | ||
| 78 | readonly exhaustExecutions: (customerId: string) => Effect.Effect<void, unknown>; | ||
| 79 | /** Arm a fault against a matching Autumn request (`POST /_emulate/faults`). */ | ||
| 80 | readonly armFault: (input: FaultInput) => Effect.Effect<void, unknown>; | ||
| 81 | /** Clear every armed fault (`DELETE /_emulate/faults`) — the finalizer that | ||
| 82 | * keeps a scenario's fault from leaking into the next one. */ | ||
| 83 | readonly clearFaults: () => Effect.Effect<void, unknown>; | ||
| 84 | /** Read the emulator's request ledger, filtered to one operation — used to | ||
| 85 | * prove a faulted `balances.check` was actually attempted (fail-open proof). */ | ||
| 86 | readonly ledgerFor: (operationId: string) => Effect.Effect<readonly LedgerEntry[], unknown>; | ||
| 44 | } | 87 | } |
| 45 | 88 | ||
| 46 | export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { | 89 | export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { |
| @@ -90,9 +133,89 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { · 43 unmodified lines | |||
| 90 | } | 133 | } |
| 91 | }); | 134 | }); |
| 92 | 135 | ||
| 136 | // Track exactly the plan's included allotment in one event, driving | ||
| 137 | // `remaining` (included - usage) to zero so the next check reports blocked. | ||
| 138 | const exhaustExecutions = (customerId: string) => | ||
| 139 | Effect.gen(function* () { | ||
| 140 | const response = yield* Effect.promise(() => | ||
| 141 | fetch(`${autumnUrl}/v1/balances.track`, { | ||
| 142 | method: "POST", | ||
| 143 | headers: { "content-type": "application/json" }, | ||
| 144 | body: JSON.stringify({ | ||
| 145 | customer_id: customerId, | ||
| 146 | feature_id: "executions", | ||
| 147 | value: DEFAULT_PLAN_EXECUTIONS_INCLUDED, | ||
| 148 | }), | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | if (!response.ok) { | ||
| 152 | return yield* Effect.fail( | ||
| 153 | `autumn balances.track responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | }); | ||
| 157 | |||
| 158 | const armFault = (input: FaultInput) => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const response = yield* Effect.promise(() => | ||
| 161 | fetch(`${autumnUrl}/_emulate/faults`, { | ||
| 162 | method: "POST", | ||
| 163 | headers: { "content-type": "application/json" }, | ||
| 164 | body: JSON.stringify(input), | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | if (!response.ok) { | ||
| 168 | return yield* Effect.fail( | ||
| 169 | `autumn arm fault responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, | ||
| 170 | ); | ||
| 171 | } | ||
| 172 | }); | ||
| 173 | |||
| 174 | const clearFaults = () => | ||
| 175 | Effect.gen(function* () { | ||
| 176 | const response = yield* Effect.promise(() => | ||
| 177 | fetch(`${autumnUrl}/_emulate/faults`, { method: "DELETE" }), | ||
| 178 | ); | ||
| 179 | if (!response.ok) { | ||
| 180 | return yield* Effect.fail( | ||
| 181 | `autumn clear faults responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | }); | ||
| 185 | |||
| 186 | const ledgerFor = (operationId: string) => | ||
| 187 | Effect.gen(function* () { | ||
| 188 | const response = yield* Effect.promise(() => fetch(`${autumnUrl}/_emulate/ledger`)); | ||
| 189 | if (!response.ok) { | ||
| 190 | return yield* Effect.fail( | ||
| 191 | `autumn ledger responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, | ||
| 192 | ); | ||
| 193 | } | ||
| 194 | const body = (yield* Effect.promise(() => response.json())) as { | ||
| 195 | readonly entries?: ReadonlyArray<{ | ||
| 196 | readonly operationId?: string; | ||
| 197 | readonly method?: string; | ||
| 198 | readonly path?: string; | ||
| 199 | readonly faulted?: boolean; | ||
| 200 | }>; | ||
| 201 | }; | ||
| 202 | return (body.entries ?? []) | ||
| 203 | .filter((entry) => entry.operationId === operationId) | ||
| 204 | .map((entry) => ({ | ||
| 205 | operationId: entry.operationId, | ||
| 206 | method: entry.method ?? "", | ||
| 207 | path: entry.path ?? "", | ||
| 208 | faulted: entry.faulted === true, | ||
| 209 | })); | ||
| 210 | }); | ||
| 211 | |||
| 93 | return { | 212 | return { |
| 94 | usageEvents, | 213 | usageEvents, |
| 95 | settleCheckout, | 214 | settleCheckout, |
| 215 | exhaustExecutions, | ||
| 216 | armFault, | ||
| 217 | clearFaults, | ||
| 218 | ledgerFor, | ||
| 96 | expectUsage: (query) => | 219 | expectUsage: (query) => |
| 97 | usageEvents(query).pipe( | 220 | usageEvents(query).pipe( |
| 98 | Effect.filterOrFail( | 221 | Effect.filterOrFail( |
| @@ -11,14 +11,14 @@ · 10 unmodified lines | |||
| 11 | "executor", | 11 | "executor", |
| 12 | "local-first" | 12 | "local-first" |
| 13 | ], | 13 | ], |
| 14 | "homepage": "https://github.com/RhysSullivan/executor", | 14 | "homepage": "https://github.com/UsefulSoftwareCo/executor", |
| 15 | "bugs": { | 15 | "bugs": { |
| 16 | "url": "https://github.com/RhysSullivan/executor/issues" | 16 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 17 | }, | 17 | }, |
| 18 | "license": "MIT", | 18 | "license": "MIT", |
| 19 | "repository": { | 19 | "repository": { |
| 20 | "type": "git", | 20 | "type": "git", |
| 21 | "url": "git+https://github.com/RhysSullivan/executor.git" | 21 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git" |
| 22 | }, | 22 | }, |
| 23 | "workspaces": [ | 23 | "workspaces": [ |
| 24 | "packages/*/*", | 24 | "packages/*/*", |
| @@ -13,7 +13,9 @@ export default defineConfig({ · 12 unmodified lines | |||
| 13 | plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })], | 13 | plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })], |
| 14 | define: { | 14 | define: { |
| 15 | "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-dev"), | 15 | "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-dev"), |
| 16 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), | 16 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify( |
| 17 | "https://github.com/UsefulSoftwareCo/executor", | ||
| 18 | ), | ||
| 17 | "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), | 19 | "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), |
| 18 | }, | 20 | }, |
| 19 | }); | 21 | }); |
| @@ -103,6 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 102 unmodified lines | |||
| 103 | clientId: payload.clientId, | 103 | clientId: payload.clientId, |
| 104 | clientSecret: payload.clientSecret, | 104 | clientSecret: payload.clientSecret, |
| 105 | resource: payload.resource ?? null, | 105 | resource: payload.resource ?? null, |
| 106 | origin: { kind: "manual", integration: payload.originIntegration ?? null }, | ||
| 106 | }); | 107 | }); |
| 107 | return { client }; | 108 | return { client }; |
| 108 | }), | 109 | }), |
| @@ -115,6 +116,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 6 unmodified lines | |||
| 115 | const client = yield* executor.oauth.registerDynamicClient({ | 116 | const client = yield* executor.oauth.registerDynamicClient({ |
| 116 | owner: payload.owner, | 117 | owner: payload.owner, |
| 117 | slug: payload.slug, | 118 | slug: payload.slug, |
| 119 | issuer: payload.issuer ?? null, | ||
| 118 | registrationEndpoint: payload.registrationEndpoint, | 120 | registrationEndpoint: payload.registrationEndpoint, |
| 119 | authorizationUrl: payload.authorizationUrl, | 121 | authorizationUrl: payload.authorizationUrl, |
| 120 | tokenUrl: payload.tokenUrl, | 122 | tokenUrl: payload.tokenUrl, |
| @@ -65,6 +65,9 @@ const CreateClientPayload = Schema.Struct({ · 64 unmodified lines | |||
| 65 | clientId: Schema.String, | 65 | clientId: Schema.String, |
| 66 | clientSecret: Schema.String, | 66 | clientSecret: Schema.String, |
| 67 | resource: Schema.optional(Schema.NullOr(Schema.String)), | 67 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| 68 | /** Integration whose connect dialog registered this manual app. Recorded so | ||
| 69 | * the picker matches it to this integration by intent, not root domain. */ | ||
| 70 | originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)), | ||
| 68 | }); | 71 | }); |
| 69 | 72 | ||
| 70 | const CreateClientResponse = Schema.Struct({ | 73 | const CreateClientResponse = Schema.Struct({ |
| @@ -81,6 +84,7 @@ const CreateClientResponse = Schema.Struct({ · 10 unmodified lines | |||
| 81 | const RegisterDynamicPayload = Schema.Struct({ | 84 | const RegisterDynamicPayload = Schema.Struct({ |
| 82 | owner: Owner, | 85 | owner: Owner, |
| 83 | slug: OAuthClientSlug, | 86 | slug: OAuthClientSlug, |
| 87 | issuer: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 84 | registrationEndpoint: Schema.String, | 88 | registrationEndpoint: Schema.String, |
| 85 | authorizationUrl: Schema.String, | 89 | authorizationUrl: Schema.String, |
| 86 | tokenUrl: Schema.String, | 90 | tokenUrl: Schema.String, |
| @@ -203,6 +207,7 @@ const ProbePayload = Schema.Struct({ · 116 unmodified lines | |||
| 203 | }); | 207 | }); |
| 204 | 208 | ||
| 205 | const ProbeResponse = Schema.Struct({ | 209 | const ProbeResponse = Schema.Struct({ |
| 210 | issuer: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 206 | authorizationUrl: Schema.String, | 211 | authorizationUrl: Schema.String, |
| 207 | tokenUrl: Schema.String, | 212 | tokenUrl: Schema.String, |
| 208 | resource: Schema.optional(Schema.NullOr(Schema.String)), | 213 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -2,14 +2,14 @@ · 1 unmodified line | |||
| 2 | "name": "@executor-js/cli", | 2 | "name": "@executor-js/cli", |
| 3 | "version": "0.2.34", | 3 | "version": "0.2.34", |
| 4 | "description": "CLI for the executor SDK — schema generation, migrations", | 4 | "description": "CLI for the executor SDK — schema generation, migrations", |
| 5 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/cli", | 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli", |
| 6 | "bugs": { | 6 | "bugs": { |
| 7 | "url": "https://github.com/RhysSullivan/executor/issues" | 7 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 8 | }, | 8 | }, |
| 9 | "license": "MIT", | 9 | "license": "MIT", |
| 10 | "repository": { | 10 | "repository": { |
| 11 | "type": "git", | 11 | "type": "git", |
| 12 | "url": "git+https://github.com/RhysSullivan/executor.git", | 12 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 13 | "directory": "packages/core/cli" | 13 | "directory": "packages/core/cli" |
| 14 | }, | 14 | }, |
| 15 | "bin": { | 15 | "bin": { |
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/config", | 2 | "name": "@executor-js/config", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/config", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/core/config" | 12 | "directory": "packages/core/config" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -126,7 +126,7 @@ import { createExecutionEngine } from "@executor-js/execution/core"; · 125 unmodified lines | |||
| 126 | 126 | ||
| 127 | ## Status | 127 | ## Status |
| 128 | 128 | ||
| 129 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 129 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 130 | 130 | ||
| 131 | ## License | 131 | ## License |
| 132 | 132 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/execution", | 2 | "name": "@executor-js/execution", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/execution", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/core/execution" | 12 | "directory": "packages/core/execution" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | |||
| 4 | "version": "1.5.7", | 4 | "version": "1.5.7", |
| 5 | "repository": { | 5 | "repository": { |
| 6 | "type": "git", | 6 | "type": "git", |
| 7 | "url": "git+https://github.com/RhysSullivan/executor.git", | 7 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 8 | "directory": "packages/core/fumadb" | 8 | "directory": "packages/core/fumadb" |
| 9 | }, | 9 | }, |
| 10 | "author": "Fuma Nama", | 10 | "author": "Fuma Nama", |
| @@ -2,14 +2,14 @@ · 1 unmodified line | |||
| 2 | "name": "@executor-js/integrations-registry", | 2 | "name": "@executor-js/integrations-registry", |
| 3 | "version": "0.1.0", | 3 | "version": "0.1.0", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/integrations-registry", | 5 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/integrations-registry", |
| 6 | "bugs": { | 6 | "bugs": { |
| 7 | "url": "https://github.com/RhysSullivan/executor/issues" | 7 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 8 | }, | 8 | }, |
| 9 | "license": "MIT", | 9 | "license": "MIT", |
| 10 | "repository": { | 10 | "repository": { |
| 11 | "type": "git", | 11 | "type": "git", |
| 12 | "url": "git+https://github.com/RhysSullivan/executor.git", | 12 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 13 | "directory": "packages/core/integrations-registry" | 13 | "directory": "packages/core/integrations-registry" |
| 14 | }, | 14 | }, |
| 15 | "files": [ | 15 | "files": [ |
| @@ -126,7 +126,7 @@ console.log(await executor.secrets.get("api-token")); // "sk_live_xxx" · 125 unmodified lines | |||
| 126 | await executor.close(); | 126 | await executor.close(); |
| 127 | ``` | 127 | ``` |
| 128 | 128 | ||
| 129 | The same pattern is what every shipped `@executor-js/plugin-*` package does internally. See [`packages/plugins/file-secrets`](https://github.com/RhysSullivan/executor/tree/main/packages/plugins/file-secrets) for a production example that backs `secretProviders` with an XDG-located JSON file. | 129 | The same pattern is what every shipped `@executor-js/plugin-*` package does internally. See [`packages/plugins/file-secrets`](https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets) for a production example that backs `secretProviders` with an XDG-located JSON file. |
| 130 | 130 | ||
| 131 | ## Plugins | 131 | ## Plugins |
| 132 | 132 | ||
| @@ -165,7 +165,7 @@ Plugins that need a token — HTTP-backed sources, OAuth flows, etc. — accept · 32 unmodified lines | |||
| 165 | 165 | ||
| 166 | ## Status | 166 | ## Status |
| 167 | 167 | ||
| 168 | Pre-`1.0`. APIs may still change between beta releases. See the [executor monorepo](https://github.com/RhysSullivan/executor) for the current development branch and roadmap. | 168 | Pre-`1.0`. APIs may still change between beta releases. See the [executor monorepo](https://github.com/UsefulSoftwareCo/executor) for the current development branch and roadmap. |
| 169 | 169 | ||
| 170 | ## License | 170 | ## License |
| 171 | 171 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/sdk", | 2 | "name": "@executor-js/sdk", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/sdk", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/core/sdk" | 12 | "directory": "packages/core/sdk" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -96,7 +96,8 @@ · 81 unmodified lines | |||
| 96 | "@executor-js/fumadb": "workspace:*", | 96 | "@executor-js/fumadb": "workspace:*", |
| 97 | "@standard-schema/spec": "^1.1.0", | 97 | "@standard-schema/spec": "^1.1.0", |
| 98 | "fractional-indexing": "^3.2.0", | 98 | "fractional-indexing": "^3.2.0", |
| 99 | "oauth4webapi": "^3.8.5" | 99 | "oauth4webapi": "^3.8.5", |
| 100 | "tldts": "^7.0.28" | ||
| 100 | }, | 101 | }, |
| 101 | "devDependencies": { | 102 | "devDependencies": { |
| 102 | "@effect/atom-react": "catalog:", | 103 | "@effect/atom-react": "catalog:", |
| @@ -223,6 +223,14 @@ export const coreTables = defineTables({ · 222 unmodified lines | |||
| 223 | // "manual" by the service layer. | 223 | // "manual" by the service layer. |
| 224 | origin_kind: nullableTextColumn("origin_kind"), | 224 | origin_kind: nullableTextColumn("origin_kind"), |
| 225 | origin_integration: nullableTextColumn("origin_integration"), | 225 | origin_integration: nullableTextColumn("origin_integration"), |
| 226 | // Authorization-server issuer that owns a DCR client, keying per-AS reuse. | ||
| 227 | // For a NEW DCR registration this is the DISCOVERED OIDC issuer (real | ||
| 228 | // information from the AS metadata, which can legitimately differ from what | ||
| 229 | // token_url would suggest). For a BACKFILLED legacy row it is instead a | ||
| 230 | // derived registrable-origin of token_url (a cache of the pure | ||
| 231 | // `registrableOriginOfUrl`, since no discovered issuer was ever recorded). | ||
| 232 | // Null for manual clients and legacy rows not yet backfilled. | ||
| 233 | origin_issuer: nullableTextColumn("origin_issuer"), | ||
| 226 | created_at: dateColumn("created_at"), | 234 | created_at: dateColumn("created_at"), |
| 227 | }, | 235 | }, |
| 228 | ["tenant", "owner", "subject", "slug"], | 236 | ["tenant", "owner", "subject", "slug"], |
| @@ -243,6 +243,9 @@ const OAuthCreateClientInput = Schema.Struct({ · 242 unmodified lines | |||
| 243 | grant: OAuthGrantSchema, | 243 | grant: OAuthGrantSchema, |
| 244 | clientId: Schema.String, | 244 | clientId: Schema.String, |
| 245 | resource: Schema.optional(Schema.NullOr(Schema.String)), | 245 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| 246 | /** Integration whose connect dialog registered this manual app. Recorded so | ||
| 247 | * the picker can match it by intent (exact) instead of by root domain. */ | ||
| 248 | originIntegration: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 246 | }); | 249 | }); |
| 247 | // Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret | 250 | // Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret |
| 248 | // fields the form pre-fills. The client secret is typed by the human in the web | 251 | // fields the form pre-fills. The client secret is typed by the human in the web |
| @@ -269,6 +272,7 @@ const OAuthClientOutputRef = Schema.Struct({ · 20 unmodified lines | |||
| 269 | const OAuthRegisterDynamicInput = Schema.Struct({ | 272 | const OAuthRegisterDynamicInput = Schema.Struct({ |
| 270 | owner: OwnerSchema, | 273 | owner: OwnerSchema, |
| 271 | slug: Schema.String, | 274 | slug: Schema.String, |
| 275 | issuer: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 272 | registrationEndpoint: Schema.String, | 276 | registrationEndpoint: Schema.String, |
| 273 | authorizationUrl: Schema.String, | 277 | authorizationUrl: Schema.String, |
| 274 | tokenUrl: Schema.String, | 278 | tokenUrl: Schema.String, |
| @@ -287,6 +291,7 @@ const OAuthProbeInput = Schema.Struct({ · 12 unmodified lines | |||
| 287 | url: Schema.String, | 291 | url: Schema.String, |
| 288 | }); | 292 | }); |
| 289 | const OAuthProbeOutput = Schema.Struct({ | 293 | const OAuthProbeOutput = Schema.Struct({ |
| 294 | issuer: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 290 | authorizationUrl: Schema.String, | 295 | authorizationUrl: Schema.String, |
| 291 | tokenUrl: Schema.String, | 296 | tokenUrl: Schema.String, |
| 292 | resource: Schema.optional(Schema.NullOr(Schema.String)), | 297 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -711,6 +716,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 418 unmodified lines | |||
| 711 | // `oauth.clients.createHandoff`. | 716 | // `oauth.clients.createHandoff`. |
| 712 | clientSecret: "", | 717 | clientSecret: "", |
| 713 | resource: input.resource ?? null, | 718 | resource: input.resource ?? null, |
| 719 | origin: { | ||
| 720 | kind: "manual", | ||
| 721 | integration: | ||
| 722 | input.originIntegration == null | ||
| 723 | ? null | ||
| 724 | : IntegrationSlug.make(input.originIntegration), | ||
| 725 | }, | ||
| 714 | }), | 726 | }), |
| 715 | (client) => ({ client: String(client) }), | 727 | (client) => ({ client: String(client) }), |
| 716 | ), | 728 | ), |
| @@ -748,6 +760,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 31 unmodified lines | |||
| 748 | ctx.oauth.registerDynamicClient({ | 760 | ctx.oauth.registerDynamicClient({ |
| 749 | owner: input.owner as Owner, | 761 | owner: input.owner as Owner, |
| 750 | slug: OAuthClientSlug.make(input.slug), | 762 | slug: OAuthClientSlug.make(input.slug), |
| 763 | issuer: input.issuer ?? null, | ||
| 751 | registrationEndpoint: input.registrationEndpoint, | 764 | registrationEndpoint: input.registrationEndpoint, |
| 752 | authorizationUrl: input.authorizationUrl, | 765 | authorizationUrl: input.authorizationUrl, |
| 753 | tokenUrl: input.tokenUrl, | 766 | tokenUrl: input.tokenUrl, |
| @@ -789,6 +802,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 35 unmodified lines | |||
| 789 | outputSchema: OAuthProbeOutputStd, | 802 | outputSchema: OAuthProbeOutputStd, |
| 790 | execute: (input: typeof OAuthProbeInput.Type, { ctx }) => | 803 | execute: (input: typeof OAuthProbeInput.Type, { ctx }) => |
| 791 | Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({ | 804 | Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({ |
| 805 | issuer: result.issuer ?? null, | ||
| 792 | authorizationUrl: result.authorizationUrl, | 806 | authorizationUrl: result.authorizationUrl, |
| 793 | tokenUrl: result.tokenUrl, | 807 | tokenUrl: result.tokenUrl, |
| 794 | resource: result.resource ?? null, | 808 | resource: result.resource ?? null, |
| @@ -386,6 +386,19 @@ export { · 385 unmodified lines | |||
| 386 | runSqliteConfigBlobMigration, | 386 | runSqliteConfigBlobMigration, |
| 387 | type SqliteConfigBlobMigrationOptions, | 387 | type SqliteConfigBlobMigrationOptions, |
| 388 | } from "./sqlite-config-blob-migration"; | 388 | } from "./sqlite-config-blob-migration"; |
| 389 | // DCR oauth_client GC + issuer backfill: shared classification predicates and | ||
| 390 | // the libSQL boot-migration body (issue #1120, Part C). | ||
| 391 | export { | ||
| 392 | classifyOAuthClientGc, | ||
| 393 | isDcrClassifiedRow, | ||
| 394 | registrableOriginOfUrl, | ||
| 395 | type OAuthClientGcDecision, | ||
| 396 | type OAuthClientGcRow, | ||
| 397 | } from "./oauth-gc"; | ||
| 398 | export { | ||
| 399 | oauthClientGcSqliteMigration, | ||
| 400 | runSqliteOAuthClientGcMigration, | ||
| 401 | } from "./sqlite-oauth-client-gc-migration"; | ||
| 389 | export { | 402 | export { |
| 390 | authToolFailure, | 403 | authToolFailure, |
| 391 | type AuthToolFailureCode, | 404 | type AuthToolFailureCode, |
| @@ -64,7 +64,15 @@ export interface OAuthClient { · 63 unmodified lines | |||
| 64 | } | 64 | } |
| 65 | 65 | ||
| 66 | export type OAuthClientOrigin = | 66 | export type OAuthClientOrigin = |
| 67 | | { readonly kind: "manual" } | 67 | | { |
| 68 | readonly kind: "manual"; | ||
| 69 | /** Integration whose connect dialog registered this manual app, when the | ||
| 70 | * registration happened from within one. Lets the picker match a BYO app | ||
| 71 | * to its integration by recorded intent (exact) instead of guessing by | ||
| 72 | * root domain. Null for apps registered outside any integration context | ||
| 73 | * (and for legacy rows predating the stamp). */ | ||
| 74 | readonly integration?: IntegrationSlug | null; | ||
| 75 | } | ||
| 68 | | { | 76 | | { |
| 69 | readonly kind: "dynamic_client_registration"; | 77 | readonly kind: "dynamic_client_registration"; |
| 70 | readonly integration?: IntegrationSlug | null; | 78 | readonly integration?: IntegrationSlug | null; |
| @@ -72,6 +80,7 @@ export type OAuthClientOrigin = · 1 unmodified line | |||
| 72 | 80 | ||
| 73 | export type CreateOAuthClientInput = OAuthClient & { | 81 | export type CreateOAuthClientInput = OAuthClient & { |
| 74 | readonly origin?: OAuthClientOrigin; | 82 | readonly origin?: OAuthClientOrigin; |
| 83 | readonly originIssuer?: string | null; | ||
| 75 | }; | 84 | }; |
| 76 | 85 | ||
| 77 | /** Metadata-only projection of a registered client for listing in the UI. | 86 | /** Metadata-only projection of a registered client for listing in the UI. |
| @@ -134,6 +143,8 @@ export interface OAuthProbeInput { · 56 unmodified lines | |||
| 134 | } | 143 | } |
| 135 | 144 | ||
| 136 | export interface OAuthProbeResult { | 145 | export interface OAuthProbeResult { |
| 146 | /** RFC 8414 authorization-server issuer. Used to key DCR clients per AS. */ | ||
| 147 | readonly issuer?: string | null; | ||
| 137 | readonly authorizationUrl: string; | 148 | readonly authorizationUrl: string; |
| 138 | readonly tokenUrl: string; | 149 | readonly tokenUrl: string; |
| 139 | /** RFC 8707 resource indicator discovered from protected-resource metadata. | 150 | /** RFC 8707 resource indicator discovered from protected-resource metadata. |
| @@ -157,6 +168,8 @@ export interface OAuthProbeResult { · 17 unmodified lines | |||
| 157 | export interface RegisterDynamicClientInput { | 168 | export interface RegisterDynamicClientInput { |
| 158 | readonly owner: Owner; | 169 | readonly owner: Owner; |
| 159 | readonly slug: OAuthClientSlug; | 170 | readonly slug: OAuthClientSlug; |
| 171 | /** RFC 8414 authorization-server issuer, when discovered before DCR. */ | ||
| 172 | readonly issuer?: string | null; | ||
| 160 | /** RFC 7591 registration endpoint advertised by the authorization server. */ | 173 | /** RFC 7591 registration endpoint advertised by the authorization server. */ |
| 161 | readonly registrationEndpoint: string; | 174 | readonly registrationEndpoint: string; |
| 162 | readonly authorizationUrl: string; | 175 | readonly authorizationUrl: string; |
| @@ -0,0 +1,221 @@ | |||
| 1 | import { describe, expect, test } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | classifyOAuthClientGc, | ||
| 5 | isDcrClassifiedRow, | ||
| 6 | registrableOriginOfUrl, | ||
| 7 | type OAuthClientGcRow, | ||
| 8 | } from "./oauth-gc"; | ||
| 9 | |||
| 10 | // The GC decision matrix (issue #1120, Part C). These lock the conjunctive, | ||
| 11 | // fail-safe deletion predicate the local libSQL migration and the cloud code | ||
| 12 | // migration both encode: delete ⇔ classified-DCR AND zero referencing | ||
| 13 | // connections. Everything else is kept. | ||
| 14 | |||
| 15 | const explicitDcr: OAuthClientGcRow = { | ||
| 16 | grant: "authorization_code", | ||
| 17 | resource: "https://cloudflare.example/mcp", | ||
| 18 | origin_kind: "dynamic_client_registration", | ||
| 19 | }; | ||
| 20 | |||
| 21 | const legacyDcr: OAuthClientGcRow = { | ||
| 22 | // Pre-Part-A row: null origin_kind, auth-code grant, carries a resource. | ||
| 23 | grant: "authorization_code", | ||
| 24 | resource: "https://cloudflare.example/mcp", | ||
| 25 | origin_kind: null, | ||
| 26 | }; | ||
| 27 | |||
| 28 | const manual: OAuthClientGcRow = { | ||
| 29 | grant: "authorization_code", | ||
| 30 | resource: null, | ||
| 31 | origin_kind: "manual", | ||
| 32 | }; | ||
| 33 | |||
| 34 | describe("isDcrClassifiedRow", () => { | ||
| 35 | test("classifies an explicit-origin DCR row as DCR", () => { | ||
| 36 | expect(isDcrClassifiedRow(explicitDcr)).toBe(true); | ||
| 37 | }); | ||
| 38 | |||
| 39 | test("classifies a legacy null-origin auth-code row with a resource as DCR", () => { | ||
| 40 | // The resource parameter (RFC 8707) is only ever set by the DCR/MCP connect | ||
| 41 | // path, so an auth-code legacy row carrying one is a DCR client. | ||
| 42 | expect(isDcrClassifiedRow(legacyDcr)).toBe(true); | ||
| 43 | // The prod-shaped case the old `…mcp…` slug regex misclassified as manual: | ||
| 44 | // a bare provider-name slug (`cloudflare`) with an MCP resource. The slug no | ||
| 45 | // longer participates in classification; the resource alone makes it DCR. | ||
| 46 | expect( | ||
| 47 | isDcrClassifiedRow({ | ||
| 48 | grant: "authorization_code", | ||
| 49 | resource: "https://mcp.cloudflare.com/mcp", | ||
| 50 | origin_kind: null, | ||
| 51 | }), | ||
| 52 | ).toBe(true); | ||
| 53 | // Other bare-slug providers the old regex missed (linear, notion): their | ||
| 54 | // MCP resources classify them as DCR regardless of slug. | ||
| 55 | expect( | ||
| 56 | isDcrClassifiedRow({ | ||
| 57 | grant: "authorization_code", | ||
| 58 | resource: "https://mcp.linear.app/mcp", | ||
| 59 | origin_kind: null, | ||
| 60 | }), | ||
| 61 | ).toBe(true); | ||
| 62 | expect( | ||
| 63 | isDcrClassifiedRow({ | ||
| 64 | grant: "authorization_code", | ||
| 65 | resource: "https://mcp.notion.com/mcp", | ||
| 66 | origin_kind: null, | ||
| 67 | }), | ||
| 68 | ).toBe(true); | ||
| 69 | }); | ||
| 70 | |||
| 71 | test("an explicit manual stamp wins even when the row carries a resource", () => { | ||
| 72 | expect(isDcrClassifiedRow(manual)).toBe(false); | ||
| 73 | // Stamped-manual BYO row WITH a resource: the stamp wins, stays manual. | ||
| 74 | expect( | ||
| 75 | isDcrClassifiedRow({ | ||
| 76 | grant: "authorization_code", | ||
| 77 | resource: "https://cloudflare.example/mcp", | ||
| 78 | origin_kind: "manual", | ||
| 79 | }), | ||
| 80 | ).toBe(false); | ||
| 81 | }); | ||
| 82 | |||
| 83 | test("an explicit DCR stamp wins even with no resource", () => { | ||
| 84 | expect( | ||
| 85 | isDcrClassifiedRow({ | ||
| 86 | grant: "authorization_code", | ||
| 87 | resource: null, | ||
| 88 | origin_kind: "dynamic_client_registration", | ||
| 89 | }), | ||
| 90 | ).toBe(true); | ||
| 91 | }); | ||
| 92 | |||
| 93 | test("a legacy null-origin row with no resource is NOT DCR (ambiguous stays manual)", () => { | ||
| 94 | // No resource: a hand-registered app never sets one, so this is manual. | ||
| 95 | expect( | ||
| 96 | isDcrClassifiedRow({ | ||
| 97 | grant: "authorization_code", | ||
| 98 | resource: null, | ||
| 99 | origin_kind: null, | ||
| 100 | }), | ||
| 101 | ).toBe(false); | ||
| 102 | // Empty-string resource is treated the same as null. | ||
| 103 | expect( | ||
| 104 | isDcrClassifiedRow({ | ||
| 105 | grant: "authorization_code", | ||
| 106 | resource: "", | ||
| 107 | origin_kind: null, | ||
| 108 | }), | ||
| 109 | ).toBe(false); | ||
| 110 | }); | ||
| 111 | |||
| 112 | test("a legacy client_credentials row with a resource is NOT DCR", () => { | ||
| 113 | // Only the auth-code grant path mints DCR clients; client_credentials with a | ||
| 114 | // resource is a manual BYO app. | ||
| 115 | expect( | ||
| 116 | isDcrClassifiedRow({ | ||
| 117 | grant: "client_credentials", | ||
| 118 | resource: "https://cloudflare.example/mcp", | ||
| 119 | origin_kind: null, | ||
| 120 | }), | ||
| 121 | ).toBe(false); | ||
| 122 | }); | ||
| 123 | }); | ||
| 124 | |||
| 125 | describe("classifyOAuthClientGc decision matrix", () => { | ||
| 126 | test("DCR + orphaned (zero connections) → delete", () => { | ||
| 127 | expect(classifyOAuthClientGc(explicitDcr, 0)).toEqual({ | ||
| 128 | action: "delete", | ||
| 129 | reason: "dcr-orphaned", | ||
| 130 | }); | ||
| 131 | expect(classifyOAuthClientGc(legacyDcr, 0)).toEqual({ | ||
| 132 | action: "delete", | ||
| 133 | reason: "dcr-orphaned", | ||
| 134 | }); | ||
| 135 | }); | ||
| 136 | |||
| 137 | test("DCR + referenced (>=1 connection) → keep", () => { | ||
| 138 | expect(classifyOAuthClientGc(explicitDcr, 1)).toEqual({ | ||
| 139 | action: "keep", | ||
| 140 | reason: "referenced", | ||
| 141 | }); | ||
| 142 | expect(classifyOAuthClientGc(legacyDcr, 3)).toEqual({ | ||
| 143 | action: "keep", | ||
| 144 | reason: "referenced", | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | test("manual + orphaned → keep (a hand-registered app is never GC'd)", () => { | ||
| 149 | expect(classifyOAuthClientGc(manual, 0)).toEqual({ action: "keep", reason: "not-dcr" }); | ||
| 150 | }); | ||
| 151 | |||
| 152 | test("manual + referenced → keep", () => { | ||
| 153 | expect(classifyOAuthClientGc(manual, 5)).toEqual({ action: "keep", reason: "not-dcr" }); | ||
| 154 | }); | ||
| 155 | |||
| 156 | test("legacy resource-classified DCR is treated exactly like an explicit DCR row", () => { | ||
| 157 | // Same (row-shape, count) inputs produce the same decision whether the DCR | ||
| 158 | // classification came from origin_kind or the resource heuristic. | ||
| 159 | expect(classifyOAuthClientGc(legacyDcr, 0).action).toBe( | ||
| 160 | classifyOAuthClientGc(explicitDcr, 0).action, | ||
| 161 | ); | ||
| 162 | expect(classifyOAuthClientGc(legacyDcr, 2).action).toBe( | ||
| 163 | classifyOAuthClientGc(explicitDcr, 2).action, | ||
| 164 | ); | ||
| 165 | }); | ||
| 166 | |||
| 167 | test("idempotency: once a DCR row is referenced (kept), a re-run keeps it (never deletes)", () => { | ||
| 168 | // A second GC pass sees the same referenced DCR rows and the same manual | ||
| 169 | // rows; none flip to delete. (Orphaned DCR rows are gone after pass 1, so | ||
| 170 | // the only rows a second pass sees are keeps.) | ||
| 171 | for (const count of [1, 2, 10]) { | ||
| 172 | expect(classifyOAuthClientGc(explicitDcr, count).action).toBe("keep"); | ||
| 173 | expect(classifyOAuthClientGc(legacyDcr, count).action).toBe("keep"); | ||
| 174 | } | ||
| 175 | expect(classifyOAuthClientGc(manual, 0).action).toBe("keep"); | ||
| 176 | }); | ||
| 177 | }); | ||
| 178 | |||
| 179 | describe("registrableOriginOfUrl (issuer backfill value)", () => { | ||
| 180 | test("collapses a subdomain token host to its registrable origin", () => { | ||
| 181 | expect(registrableOriginOfUrl("https://oauth.cloudflare.com/token")).toBe( | ||
| 182 | "https://cloudflare.com", | ||
| 183 | ); | ||
| 184 | expect(registrableOriginOfUrl("https://login.microsoftonline.com/common/oauth2/token")).toBe( | ||
| 185 | "https://microsoftonline.com", | ||
| 186 | ); | ||
| 187 | }); | ||
| 188 | |||
| 189 | test("keeps an already-registrable host as-is", () => { | ||
| 190 | expect(registrableOriginOfUrl("https://github.com/login/oauth/access_token")).toBe( | ||
| 191 | "https://github.com", | ||
| 192 | ); | ||
| 193 | }); | ||
| 194 | |||
| 195 | test("respects multi-label public suffixes via the full PSL", () => { | ||
| 196 | expect(registrableOriginOfUrl("https://api.foo.co.uk/token")).toBe("https://foo.co.uk"); | ||
| 197 | // Suffixes the old hardcoded 12-entry list missed: .co.in, .com.cn, .co.za. | ||
| 198 | // `accounts.example.co.in` must collapse to `example.co.in`, not `co.in`. | ||
| 199 | expect(registrableOriginOfUrl("https://accounts.example.co.in/token")).toBe( | ||
| 200 | "https://example.co.in", | ||
| 201 | ); | ||
| 202 | expect(registrableOriginOfUrl("https://oauth.example.com.cn/token")).toBe( | ||
| 203 | "https://example.com.cn", | ||
| 204 | ); | ||
| 205 | expect(registrableOriginOfUrl("https://login.example.co.za/token")).toBe( | ||
| 206 | "https://example.co.za", | ||
| 207 | ); | ||
| 208 | }); | ||
| 209 | |||
| 210 | test("preserves scheme and port for loopback / dev token hosts", () => { | ||
| 211 | expect(registrableOriginOfUrl("http://127.0.0.1:8787/token")).toBe("http://127.0.0.1:8787"); | ||
| 212 | expect(registrableOriginOfUrl("http://localhost:4788/oauth/token")).toBe( | ||
| 213 | "http://localhost:4788", | ||
| 214 | ); | ||
| 215 | }); | ||
| 216 | |||
| 217 | test("returns null for a non-URL token value", () => { | ||
| 218 | expect(registrableOriginOfUrl("not a url")).toBeNull(); | ||
| 219 | expect(registrableOriginOfUrl("")).toBeNull(); | ||
| 220 | }); | ||
| 221 | }); | ||
| @@ -0,0 +1,156 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Garbage collection + backfill for legacy Dynamic Client Registration (DCR) | ||
| 3 | // `oauth_client` rows (issue #1120, Part C). | ||
| 4 | // | ||
| 5 | // Old always-register behavior minted a fresh `oauth_client` per connect | ||
| 6 | // attempt ("cloudflare-mcp", "cloudflare-mcp-2", …). Parts A+B stopped the | ||
| 7 | // multiplication (per-AS reuse) and hid the rows from pickers, but the dead | ||
| 8 | // rows remain and legacy rows have `origin_issuer = NULL`, so the reuse lookup | ||
| 9 | // can't key on them. This module supplies the shared, testable predicates that | ||
| 10 | // both host paths (local libSQL boot migration, cloud Drizzle SQL migration) | ||
| 11 | // encode: | ||
| 12 | // | ||
| 13 | // 1. GC: delete a row that is classified DCR AND has ZERO referencing | ||
| 14 | // connections. The predicate is conjunctive and fail-safe — an ambiguous | ||
| 15 | // row (manual, or DCR but still referenced) is always KEPT. | ||
| 16 | // 2. Backfill: for a surviving DCR row with `origin_issuer IS NULL`, set it | ||
| 17 | // to the registrable origin of `token_url` so the per-AS reuse lookup can | ||
| 18 | // find it and mint no new duplicate. | ||
| 19 | // | ||
| 20 | // The predicates here are the SINGLE source of truth, imported (never | ||
| 21 | // re-encoded) by every call site: `oauth-service`'s `parseOAuthClientOrigin` | ||
| 22 | // calls `isDcrClassifiedRow` for the runtime reuse lookup, and BOTH GC | ||
| 23 | // migrations (the local libSQL boot migration and the cloud code migration) | ||
| 24 | // import `classifyOAuthClientGc` / `registrableOriginOfUrl` and decide in | ||
| 25 | // process rather than rewriting the logic as SQL. There is no parallel SQL copy | ||
| 26 | // to drift: changing a predicate here changes all three call sites at once. | ||
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | |||
| 29 | import { getDomain } from "tldts"; | ||
| 30 | |||
| 31 | /** Parse a string into a URL, or null when it is not a valid absolute URL. */ | ||
| 32 | export const parseUrl = (value: string): URL | null => { | ||
| 33 | if (!URL.canParse(value)) return null; | ||
| 34 | return new URL(value); | ||
| 35 | }; | ||
| 36 | |||
| 37 | /** Canonicalize a discovered issuer to `origin` + path with trailing slashes | ||
| 38 | * stripped (RFC 8414 issuers are compared without a trailing slash). Null for | ||
| 39 | * a blank or unparseable value. Part A's DCR-identity canonicalization. */ | ||
| 40 | export const canonicalIssuerUrl = (value: string | null | undefined): string | null => { | ||
| 41 | if (value == null) return null; | ||
| 42 | const trimmed = value.trim(); | ||
| 43 | if (trimmed.length === 0) return null; | ||
| 44 | const url = parseUrl(trimmed); | ||
| 45 | if (url === null) return null; | ||
| 46 | const path = url.pathname.replace(/\/+$/g, ""); | ||
| 47 | return path.length > 0 ? `${url.origin}${path}` : url.origin; | ||
| 48 | }; | ||
| 49 | |||
| 50 | export const hostOfUrl = (value: string): string | null => | ||
| 51 | parseUrl(value)?.host.toLowerCase() ?? null; | ||
| 52 | |||
| 53 | /** The registrable domain of a hostname (eTLD+1), backed by the full Public | ||
| 54 | * Suffix List via `tldts` (so `api.foo.co.uk` → `foo.co.uk`, `x.co.in` → | ||
| 55 | * `x.co.in`, etc., not a hardcoded suffix subset). `tldts.getDomain` returns | ||
| 56 | * null for hosts with no registrable domain (`localhost`, bare IPv4/IPv6, | ||
| 57 | * single-label hosts); those fall back to the raw lowercase hostname so | ||
| 58 | * local-dev token hosts still key on their exact host. This mirrors how | ||
| 59 | * `packages/react/src/plugins/use-effective-oauth-client.tsx` uses tldts. */ | ||
| 60 | export const registrableHostname = (hostname: string): string => { | ||
| 61 | const host = hostname.toLowerCase(); | ||
| 62 | return getDomain(host) ?? host; | ||
| 63 | }; | ||
| 64 | |||
| 65 | /** The registrable ORIGIN of a URL: its scheme + the registrable host (dropping | ||
| 66 | * any subdomain). Used to backfill a legacy DCR row's `origin_issuer` from its | ||
| 67 | * `token_url`, so the per-AS reuse lookup can find it. Null when not a URL. | ||
| 68 | * | ||
| 69 | * Preserves scheme and port (a loopback `http://127.0.0.1:8787` token host | ||
| 70 | * keeps its port). Reuses `registrableHostname` — no duplicated eTLD logic. */ | ||
| 71 | export const registrableOriginOfUrl = (value: string): string | null => { | ||
| 72 | const url = parseUrl(value); | ||
| 73 | if (url === null) return null; | ||
| 74 | const registrable = registrableHostname(url.hostname); | ||
| 75 | // When the hostname collapses to a registrable domain, rebuild the origin | ||
| 76 | // from scheme + registrable host + original port; otherwise keep url.origin | ||
| 77 | // verbatim (covers localhost / IPv4 / already-registrable hosts, incl. port). | ||
| 78 | if (registrable === url.hostname.toLowerCase()) return url.origin; | ||
| 79 | const port = url.port.length > 0 ? `:${url.port}` : ""; | ||
| 80 | return `${url.protocol}//${registrable}${port}`; | ||
| 81 | }; | ||
| 82 | |||
| 83 | /** The minimal shape the DCR classifier reads off an `oauth_client` row. Every | ||
| 84 | * field is optional/unknown so it applies equally to a fuma row, a raw SQL | ||
| 85 | * row, or a hand-built test fixture. */ | ||
| 86 | export interface OAuthClientGcRow { | ||
| 87 | readonly grant?: unknown; | ||
| 88 | readonly resource?: unknown; | ||
| 89 | readonly origin_kind?: unknown; | ||
| 90 | } | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Is this `oauth_client` row a Dynamic Client Registration client? | ||
| 94 | * | ||
| 95 | * True when EITHER: | ||
| 96 | * - it carries the explicit `origin_kind = 'dynamic_client_registration'` | ||
| 97 | * stamp (rows minted after Part A), OR | ||
| 98 | * - it is a legacy null-origin row with an `authorization_code` grant AND a | ||
| 99 | * non-empty `resource`. | ||
| 100 | * | ||
| 101 | * The `resource` parameter (RFC 8707) is only ever set by the DCR/MCP connect | ||
| 102 | * path, never by the manual app form: a hand-registered app has no resource. | ||
| 103 | * So on a legacy (null origin_kind) row, an auth-code grant carrying a resource | ||
| 104 | * is a DCR client. This was validated against the production database: of the | ||
| 105 | * 394 rows that carry an explicit origin_kind stamp, `grant = | ||
| 106 | * 'authorization_code' AND resource IS NOT NULL` matches all 329 stamped DCR | ||
| 107 | * rows (100% recall). It also matches a handful of stamped-manual BYO rows | ||
| 108 | * that point at MCP servers, but those never reach this arm: an explicit | ||
| 109 | * stamp always wins. The earlier slug/resource `…mcp…` regex heuristic had | ||
| 110 | * bad recall (it missed legacy rows whose slug was a bare provider name like | ||
| 111 | * `linear` / `notion` / `cloudflare`), so it is gone; the resource presence | ||
| 112 | * check subsumes it. | ||
| 113 | * | ||
| 114 | * Explicit stamps always win (arm 1): a stamped `manual` BYO row keeps its | ||
| 115 | * classification even when it carries a resource, and a stamped DCR row is DCR | ||
| 116 | * even with no resource. The resource heuristic only decides legacy null-origin | ||
| 117 | * rows. A legacy row that does not match either arm is treated as manual and is | ||
| 118 | * NEVER a GC candidate — the conservative default that protects hand-registered | ||
| 119 | * apps. | ||
| 120 | */ | ||
| 121 | export const isDcrClassifiedRow = (row: OAuthClientGcRow): boolean => { | ||
| 122 | if (row.origin_kind === "dynamic_client_registration") return true; | ||
| 123 | if (row.origin_kind != null) return false; | ||
| 124 | if (row.grant !== "authorization_code") return false; | ||
| 125 | const resource = row.resource == null ? "" : String(row.resource); | ||
| 126 | return resource.length > 0; | ||
| 127 | }; | ||
| 128 | |||
| 129 | /** The GC decision for one `oauth_client` row. */ | ||
| 130 | export type OAuthClientGcDecision = | ||
| 131 | /** Classified DCR AND unreferenced — safe to delete. */ | ||
| 132 | | { readonly action: "delete"; readonly reason: "dcr-orphaned" } | ||
| 133 | /** Kept — either not DCR, or DCR but still referenced by a connection. */ | ||
| 134 | | { readonly action: "keep"; readonly reason: "not-dcr" | "referenced" }; | ||
| 135 | |||
| 136 | /** | ||
| 137 | * Decide whether a single `oauth_client` row should be garbage-collected. | ||
| 138 | * | ||
| 139 | * The deletion predicate is strictly conjunctive and fail-safe: | ||
| 140 | * delete ⇔ isDcrClassifiedRow(row) AND referencingConnectionCount === 0 | ||
| 141 | * | ||
| 142 | * Anything else is kept. A manual app is kept even when orphaned (users delete | ||
| 143 | * their own apps deliberately); a DCR app is kept while any connection still | ||
| 144 | * references it (deleting it would break refresh/reconnect for that | ||
| 145 | * connection). `referencingConnectionCount` is the number of `connection` rows | ||
| 146 | * whose stored `(oauth_client_owner, oauth_client)` reference resolves to this | ||
| 147 | * row's `(owner, slug)`. | ||
| 148 | */ | ||
| 149 | export const classifyOAuthClientGc = ( | ||
| 150 | row: OAuthClientGcRow, | ||
| 151 | referencingConnectionCount: number, | ||
| 152 | ): OAuthClientGcDecision => { | ||
| 153 | if (!isDcrClassifiedRow(row)) return { action: "keep", reason: "not-dcr" }; | ||
| 154 | if (referencingConnectionCount > 0) return { action: "keep", reason: "referenced" }; | ||
| 155 | return { action: "delete", reason: "dcr-orphaned" }; | ||
| 156 | }; | ||
| @@ -91,7 +91,9 @@ describe("oauth.listClients", () => { · 90 unmodified lines | |||
| 91 | tokenUrl: "https://acme.test/token", | 91 | tokenUrl: "https://acme.test/token", |
| 92 | resource: null, | 92 | resource: null, |
| 93 | clientId: "org-client-id", | 93 | clientId: "org-client-id", |
| 94 | origin: { kind: "manual" }, | 94 | // Manual apps carry a nullable recorded-intent integration; a client |
| 95 | // created outside any integration dialog stamps null. | ||
| 96 | origin: { kind: "manual", integration: null }, | ||
| 95 | }); | 97 | }); |
| 96 | expect(user!.owner).toBe("user"); | 98 | expect(user!.owner).toBe("user"); |
| 97 | expect(user!.grant).toBe("client_credentials"); | 99 | expect(user!.grant).toBe("client_credentials"); |
| @@ -25,6 +25,18 @@ const TEMPLATE = AuthTemplateSlug.make("oauth"); · 24 unmodified lines | |||
| 25 | const CLIENT = OAuthClientSlug.make("acme-dcr"); | 25 | const CLIENT = OAuthClientSlug.make("acme-dcr"); |
| 26 | const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback"; | 26 | const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback"; |
| 27 | 27 | ||
| 28 | const dcrSlugFor = (issuerUrl: string): OAuthClientSlug => | ||
| 29 | OAuthClientSlug.make( | ||
| 30 | `dcr-${new URL(issuerUrl).host | ||
| 31 | .toLowerCase() | ||
| 32 | .replace(/[^a-z0-9]+/g, "-") | ||
| 33 | .replace(/^-+|-+$/g, "")}`, | ||
| 34 | ); | ||
| 35 | |||
| 36 | const registerRequestCount = ( | ||
| 37 | requests: readonly { readonly path: string; readonly method: string }[], | ||
| 38 | ): number => requests.filter((r) => r.path === "/register" && r.method === "POST").length; | ||
| 39 | |||
| 28 | const oauthPlugin = definePlugin(() => ({ | 40 | const oauthPlugin = definePlugin(() => ({ |
| 29 | id: "acme" as const, | 41 | id: "acme" as const, |
| 30 | storage: () => ({}), | 42 | storage: () => ({}), |
| @@ -56,6 +68,7 @@ describe("oauth.registerDynamicClient", () => { · 25 unmodified lines | |||
| 56 | // Probe surfaces the registration endpoint + advertised auth methods so | 68 | // Probe surfaces the registration endpoint + advertised auth methods so |
| 57 | // the caller knows a public client is allowed. | 69 | // the caller knows a public client is allowed. |
| 58 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 70 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 71 | expect(probe.issuer).toBe(server.issuerUrl); | ||
| 59 | expect(probe.registrationEndpoint).toBe(server.registrationEndpoint); | 72 | expect(probe.registrationEndpoint).toBe(server.registrationEndpoint); |
| 60 | expect(probe.tokenEndpointAuthMethodsSupported).toContain("none"); | 73 | expect(probe.tokenEndpointAuthMethodsSupported).toContain("none"); |
| 61 | expect(probe.resource).toBe(server.mcpResourceUrl); | 74 | expect(probe.resource).toBe(server.mcpResourceUrl); |
| @@ -64,6 +77,7 @@ describe("oauth.registerDynamicClient", () => { · 2 unmodified lines | |||
| 64 | const slug = yield* executor.oauth.registerDynamicClient({ | 77 | const slug = yield* executor.oauth.registerDynamicClient({ |
| 65 | owner: "org", | 78 | owner: "org", |
| 66 | slug: CLIENT, | 79 | slug: CLIENT, |
| 80 | issuer: probe.issuer, | ||
| 67 | registrationEndpoint: probe.registrationEndpoint!, | 81 | registrationEndpoint: probe.registrationEndpoint!, |
| 68 | authorizationUrl: probe.authorizationUrl, | 82 | authorizationUrl: probe.authorizationUrl, |
| 69 | tokenUrl: probe.tokenUrl, | 83 | tokenUrl: probe.tokenUrl, |
| @@ -74,12 +88,13 @@ describe("oauth.registerDynamicClient", () => { · 4 unmodified lines | |||
| 74 | redirectUri: FLOW_REDIRECT_URI, | 88 | redirectUri: FLOW_REDIRECT_URI, |
| 75 | originIntegration: INTEG, | 89 | originIntegration: INTEG, |
| 76 | }); | 90 | }); |
| 77 | expect(String(slug)).toBe(String(CLIENT)); | 91 | const expectedSlug = dcrSlugFor(server.issuerUrl); |
| 92 | expect(String(slug)).toBe(String(expectedSlug)); | ||
| 78 | 93 | ||
| 79 | // The minted client appears in listClients with a server-issued | 94 | // The minted client appears in listClients with a server-issued |
| 80 | // client_id and NO secret ever projected. | 95 | // client_id and NO secret ever projected. |
| 81 | const clients = yield* executor.oauth.listClients(); | 96 | const clients = yield* executor.oauth.listClients(); |
| 82 | const minted = clients.find((c) => String(c.slug) === String(CLIENT)); | 97 | const minted = clients.find((c) => String(c.slug) === String(expectedSlug)); |
| 83 | expect(minted).toBeDefined(); | 98 | expect(minted).toBeDefined(); |
| 84 | expect(minted!.owner).toBe("org"); | 99 | expect(minted!.owner).toBe("org"); |
| 85 | expect(minted!.grant).toBe("authorization_code"); | 100 | expect(minted!.grant).toBe("authorization_code"); |
| @@ -98,7 +113,7 @@ describe("oauth.registerDynamicClient", () => { · 12 unmodified lines | |||
| 98 | // PKCE flow with NO client_secret on the token exchange. | 113 | // PKCE flow with NO client_secret on the token exchange. |
| 99 | const started = yield* executor.oauth.start({ | 114 | const started = yield* executor.oauth.start({ |
| 100 | owner: "org", | 115 | owner: "org", |
| 101 | client: CLIENT, | 116 | client: expectedSlug, |
| 102 | clientOwner: "org", | 117 | clientOwner: "org", |
| 103 | name: ConnectionName.make("main"), | 118 | name: ConnectionName.make("main"), |
| 104 | integration: INTEG, | 119 | integration: INTEG, |
| @@ -145,6 +160,373 @@ describe("oauth.registerDynamicClient", () => { · 40 unmodified lines | |||
| 145 | ), | 160 | ), |
| 146 | ); | 161 | ); |
| 147 | 162 | ||
| 163 | it.effect("reuses an existing DCR client for the same owner and authorization server", () => | ||
| 164 | Effect.scoped( | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 167 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 168 | yield* executor.acme.seed(); | ||
| 169 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | ||
| 170 | |||
| 171 | const first = yield* executor.oauth.registerDynamicClient({ | ||
| 172 | owner: "org", | ||
| 173 | slug: OAuthClientSlug.make("first-attempt"), | ||
| 174 | issuer: probe.issuer, | ||
| 175 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 176 | authorizationUrl: probe.authorizationUrl, | ||
| 177 | tokenUrl: probe.tokenUrl, | ||
| 178 | resource: probe.resource, | ||
| 179 | scopes: ["read"], | ||
| 180 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 181 | clientName: "Acme DCR", | ||
| 182 | redirectUri: FLOW_REDIRECT_URI, | ||
| 183 | originIntegration: INTEG, | ||
| 184 | }); | ||
| 185 | yield* server.clearRequests; | ||
| 186 | |||
| 187 | const second = yield* executor.oauth.registerDynamicClient({ | ||
| 188 | owner: "org", | ||
| 189 | slug: OAuthClientSlug.make("second-attempt"), | ||
| 190 | issuer: probe.issuer, | ||
| 191 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 192 | authorizationUrl: probe.authorizationUrl, | ||
| 193 | tokenUrl: probe.tokenUrl, | ||
| 194 | resource: probe.resource, | ||
| 195 | scopes: ["read"], | ||
| 196 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 197 | clientName: "Other integration DCR", | ||
| 198 | redirectUri: FLOW_REDIRECT_URI, | ||
| 199 | originIntegration: IntegrationSlug.make("other"), | ||
| 200 | }); | ||
| 201 | |||
| 202 | expect(second).toBe(first); | ||
| 203 | const requests = yield* server.requests; | ||
| 204 | expect(registerRequestCount(requests)).toBe(0); | ||
| 205 | }), | ||
| 206 | ), | ||
| 207 | ); | ||
| 208 | |||
| 209 | it.effect("does not reuse a DCR client across owners", () => | ||
| 210 | Effect.scoped( | ||
| 211 | Effect.gen(function* () { | ||
| 212 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 213 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 214 | yield* executor.acme.seed(); | ||
| 215 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | ||
| 216 | |||
| 217 | const orgSlug = yield* executor.oauth.registerDynamicClient({ | ||
| 218 | owner: "org", | ||
| 219 | slug: OAuthClientSlug.make("org-attempt"), | ||
| 220 | issuer: probe.issuer, | ||
| 221 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 222 | authorizationUrl: probe.authorizationUrl, | ||
| 223 | tokenUrl: probe.tokenUrl, | ||
| 224 | resource: probe.resource, | ||
| 225 | scopes: ["read"], | ||
| 226 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 227 | clientName: "Org DCR", | ||
| 228 | redirectUri: FLOW_REDIRECT_URI, | ||
| 229 | originIntegration: INTEG, | ||
| 230 | }); | ||
| 231 | yield* server.clearRequests; | ||
| 232 | |||
| 233 | const userSlug = yield* executor.oauth.registerDynamicClient({ | ||
| 234 | owner: "user", | ||
| 235 | slug: OAuthClientSlug.make("user-attempt"), | ||
| 236 | issuer: probe.issuer, | ||
| 237 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 238 | authorizationUrl: probe.authorizationUrl, | ||
| 239 | tokenUrl: probe.tokenUrl, | ||
| 240 | resource: probe.resource, | ||
| 241 | scopes: ["read"], | ||
| 242 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 243 | clientName: "User DCR", | ||
| 244 | redirectUri: FLOW_REDIRECT_URI, | ||
| 245 | originIntegration: INTEG, | ||
| 246 | }); | ||
| 247 | |||
| 248 | expect(String(userSlug)).toBe(String(orgSlug)); | ||
| 249 | const requests = yield* server.requests; | ||
| 250 | expect(registerRequestCount(requests)).toBe(1); | ||
| 251 | }), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | |||
| 255 | it.effect("does NOT reuse a legacy DCR row with no stored issuer (mints fresh)", () => | ||
| 256 | Effect.scoped( | ||
| 257 | Effect.gen(function* () { | ||
| 258 | // Post-migration, the reuse lookup keys strictly on a non-null | ||
| 259 | // origin_issuer: the fuzzy token-host fallback is gone. A legacy row that | ||
| 260 | // has not yet been backfilled (null origin_issuer) is therefore NOT | ||
| 261 | // reused, so a fresh DCR registration happens. The GC migration then | ||
| 262 | // backfills/GCs any duplicate this transient window mints. | ||
| 263 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 264 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 265 | yield* executor.acme.seed(); | ||
| 266 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | ||
| 267 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); | ||
| 268 | |||
| 269 | yield* executor.oauth.createClient({ | ||
| 270 | owner: "org", | ||
| 271 | slug: legacySlug, | ||
| 272 | authorizationUrl: probe.authorizationUrl, | ||
| 273 | tokenUrl: probe.tokenUrl, | ||
| 274 | resource: server.mcpResourceUrl, | ||
| 275 | grant: "authorization_code", | ||
| 276 | clientId: "legacy-dcr-client", | ||
| 277 | clientSecret: "", | ||
| 278 | }); | ||
| 279 | yield* Effect.promise(() => | ||
| 280 | config.db.updateMany("oauth_client", { | ||
| 281 | where: (b) => b("slug", "=", String(legacySlug)), | ||
| 282 | set: { origin_kind: null, origin_integration: null, origin_issuer: null }, | ||
| 283 | }), | ||
| 284 | ); | ||
| 285 | yield* server.clearRequests; | ||
| 286 | |||
| 287 | const registered = yield* executor.oauth.registerDynamicClient({ | ||
| 288 | owner: "org", | ||
| 289 | slug: OAuthClientSlug.make("new-attempt"), | ||
| 290 | issuer: probe.issuer, | ||
| 291 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 292 | authorizationUrl: probe.authorizationUrl, | ||
| 293 | tokenUrl: probe.tokenUrl, | ||
| 294 | resource: server.mcpResourceUrl, | ||
| 295 | scopes: ["read"], | ||
| 296 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 297 | clientName: "Acme DCR", | ||
| 298 | redirectUri: FLOW_REDIRECT_URI, | ||
| 299 | originIntegration: INTEG, | ||
| 300 | }); | ||
| 301 | |||
| 302 | // A fresh client is registered, not the null-issuer legacy row. | ||
| 303 | expect(registered).not.toBe(legacySlug); | ||
| 304 | const requests = yield* server.requests; | ||
| 305 | expect(registerRequestCount(requests)).toBe(1); | ||
| 306 | }), | ||
| 307 | ), | ||
| 308 | ); | ||
| 309 | |||
| 310 | it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () => | ||
| 311 | Effect.scoped( | ||
| 312 | Effect.gen(function* () { | ||
| 313 | // The post-backfill counterpart: after the GC migration stamps a legacy | ||
| 314 | // row's origin_issuer, the reuse lookup keys on it and mints no | ||
| 315 | // duplicate. This is the steady state the migration establishes. | ||
| 316 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 317 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 318 | yield* executor.acme.seed(); | ||
| 319 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | ||
| 320 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); | ||
| 321 | |||
| 322 | yield* executor.oauth.createClient({ | ||
| 323 | owner: "org", | ||
| 324 | slug: legacySlug, | ||
| 325 | authorizationUrl: probe.authorizationUrl, | ||
| 326 | tokenUrl: probe.tokenUrl, | ||
| 327 | resource: server.mcpResourceUrl, | ||
| 328 | grant: "authorization_code", | ||
| 329 | clientId: "legacy-dcr-client", | ||
| 330 | clientSecret: "", | ||
| 331 | }); | ||
| 332 | // Simulate the migration's backfill: legacy DCR stamp + issuer set. | ||
| 333 | yield* Effect.promise(() => | ||
| 334 | config.db.updateMany("oauth_client", { | ||
| 335 | where: (b) => b("slug", "=", String(legacySlug)), | ||
| 336 | set: { | ||
| 337 | origin_kind: "dynamic_client_registration", | ||
| 338 | origin_integration: null, | ||
| 339 | origin_issuer: probe.issuer, | ||
| 340 | }, | ||
| 341 | }), | ||
| 342 | ); | ||
| 343 | yield* server.clearRequests; | ||
| 344 | |||
| 345 | const reused = yield* executor.oauth.registerDynamicClient({ | ||
| 346 | owner: "org", | ||
| 347 | slug: OAuthClientSlug.make("new-attempt"), | ||
| 348 | issuer: probe.issuer, | ||
| 349 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 350 | authorizationUrl: probe.authorizationUrl, | ||
| 351 | tokenUrl: probe.tokenUrl, | ||
| 352 | resource: server.mcpResourceUrl, | ||
| 353 | scopes: ["read"], | ||
| 354 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 355 | clientName: "Acme DCR", | ||
| 356 | redirectUri: FLOW_REDIRECT_URI, | ||
| 357 | originIntegration: INTEG, | ||
| 358 | }); | ||
| 359 | |||
| 360 | expect(reused).toBe(legacySlug); | ||
| 361 | const requests = yield* server.requests; | ||
| 362 | expect(registerRequestCount(requests)).toBe(0); | ||
| 363 | }), | ||
| 364 | ), | ||
| 365 | ); | ||
| 366 | |||
| 367 | it.effect("uses resource to distinguish DCR clients only after an issuer already differs", () => | ||
| 368 | Effect.scoped( | ||
| 369 | Effect.gen(function* () { | ||
| 370 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 371 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 372 | yield* executor.acme.seed(); | ||
| 373 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | ||
| 374 | const resourceA = `${server.issuerUrl}/mcp/a`; | ||
| 375 | const resourceB = `${server.issuerUrl}/mcp/b`; | ||
| 376 | |||
| 377 | const first = yield* executor.oauth.registerDynamicClient({ | ||
| 378 | owner: "org", | ||
| 379 | slug: OAuthClientSlug.make("first-resource"), | ||
| 380 | issuer: probe.issuer, | ||
| 381 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 382 | authorizationUrl: probe.authorizationUrl, | ||
| 383 | tokenUrl: probe.tokenUrl, | ||
| 384 | resource: resourceA, | ||
| 385 | scopes: ["read"], | ||
| 386 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 387 | clientName: "Resource A", | ||
| 388 | redirectUri: FLOW_REDIRECT_URI, | ||
| 389 | originIntegration: INTEG, | ||
| 390 | }); | ||
| 391 | expect(String(first)).toBe(String(dcrSlugFor(server.issuerUrl))); | ||
| 392 | |||
| 393 | const second = yield* executor.oauth.registerDynamicClient({ | ||
| 394 | owner: "org", | ||
| 395 | slug: OAuthClientSlug.make("second-resource"), | ||
| 396 | issuer: probe.issuer, | ||
| 397 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 398 | authorizationUrl: probe.authorizationUrl, | ||
| 399 | tokenUrl: probe.tokenUrl, | ||
| 400 | resource: resourceB, | ||
| 401 | scopes: ["read"], | ||
| 402 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 403 | clientName: "Resource B", | ||
| 404 | redirectUri: FLOW_REDIRECT_URI, | ||
| 405 | originIntegration: INTEG, | ||
| 406 | }); | ||
| 407 | expect(String(second)).not.toBe(String(first)); | ||
| 408 | expect(String(second)).toContain(String(first)); | ||
| 409 | yield* server.clearRequests; | ||
| 410 | |||
| 411 | const third = yield* executor.oauth.registerDynamicClient({ | ||
| 412 | owner: "org", | ||
| 413 | slug: OAuthClientSlug.make("third-resource"), | ||
| 414 | issuer: probe.issuer, | ||
| 415 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 416 | authorizationUrl: probe.authorizationUrl, | ||
| 417 | tokenUrl: probe.tokenUrl, | ||
| 418 | resource: resourceB, | ||
| 419 | scopes: ["read"], | ||
| 420 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 421 | clientName: "Resource B again", | ||
| 422 | redirectUri: FLOW_REDIRECT_URI, | ||
| 423 | originIntegration: IntegrationSlug.make("other"), | ||
| 424 | }); | ||
| 425 | |||
| 426 | expect(third).toBe(second); | ||
| 427 | const requests = yield* server.requests; | ||
| 428 | expect(registerRequestCount(requests)).toBe(0); | ||
| 429 | }), | ||
| 430 | ), | ||
| 431 | ); | ||
| 432 | |||
| 433 | it.effect("does not reuse a resource-scoped DCR client for a resource-less request", () => | ||
| 434 | Effect.scoped( | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | ||
| 437 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); | ||
| 438 | yield* executor.acme.seed(); | ||
| 439 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | ||
| 440 | const resourceA = `${server.issuerUrl}/mcp/a`; | ||
| 441 | const resourceB = `${server.issuerUrl}/mcp/b`; | ||
| 442 | |||
| 443 | // Two resource-scoped clients for the same authorization server. The first | ||
| 444 | // takes the base `dcr-<host>` slug; the second gets a resource-suffixed one. | ||
| 445 | const scopedA = yield* executor.oauth.registerDynamicClient({ | ||
| 446 | owner: "org", | ||
| 447 | slug: OAuthClientSlug.make("scoped-a"), | ||
| 448 | issuer: probe.issuer, | ||
| 449 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 450 | authorizationUrl: probe.authorizationUrl, | ||
| 451 | tokenUrl: probe.tokenUrl, | ||
| 452 | resource: resourceA, | ||
| 453 | scopes: ["read"], | ||
| 454 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 455 | clientName: "Resource A", | ||
| 456 | redirectUri: FLOW_REDIRECT_URI, | ||
| 457 | originIntegration: INTEG, | ||
| 458 | }); | ||
| 459 | const scopedB = yield* executor.oauth.registerDynamicClient({ | ||
| 460 | owner: "org", | ||
| 461 | slug: OAuthClientSlug.make("scoped-b"), | ||
| 462 | issuer: probe.issuer, | ||
| 463 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 464 | authorizationUrl: probe.authorizationUrl, | ||
| 465 | tokenUrl: probe.tokenUrl, | ||
| 466 | resource: resourceB, | ||
| 467 | scopes: ["read"], | ||
| 468 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 469 | clientName: "Resource B", | ||
| 470 | redirectUri: FLOW_REDIRECT_URI, | ||
| 471 | originIntegration: INTEG, | ||
| 472 | }); | ||
| 473 | expect(String(scopedB)).not.toBe(String(scopedA)); | ||
| 474 | yield* server.clearRequests; | ||
| 475 | |||
| 476 | // A resource-LESS request for the same owner + issuer must NOT borrow | ||
| 477 | // either resource-scoped client (their tokens are bound to a resource): | ||
| 478 | // it registers a fresh resource-less client. | ||
| 479 | const resourceLess = yield* executor.oauth.registerDynamicClient({ | ||
| 480 | owner: "org", | ||
| 481 | slug: OAuthClientSlug.make("resource-less"), | ||
| 482 | issuer: probe.issuer, | ||
| 483 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 484 | authorizationUrl: probe.authorizationUrl, | ||
| 485 | tokenUrl: probe.tokenUrl, | ||
| 486 | resource: null, | ||
| 487 | scopes: ["read"], | ||
| 488 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 489 | clientName: "Resource-less", | ||
| 490 | redirectUri: FLOW_REDIRECT_URI, | ||
| 491 | originIntegration: INTEG, | ||
| 492 | }); | ||
| 493 | expect(String(resourceLess)).not.toBe(String(scopedA)); | ||
| 494 | expect(String(resourceLess)).not.toBe(String(scopedB)); | ||
| 495 | const requests = yield* server.requests; | ||
| 496 | expect(registerRequestCount(requests)).toBe(1); | ||
| 497 | |||
| 498 | // The minted resource-less client is stored with a null resource, and the | ||
| 499 | // two resource-scoped clients still exist (nothing was clobbered). | ||
| 500 | const clients = yield* executor.oauth.listClients(); | ||
| 501 | const minted = clients.find((c) => String(c.slug) === String(resourceLess)); | ||
| 502 | expect(minted).toBeDefined(); | ||
| 503 | expect(minted!.resource ?? null).toBeNull(); | ||
| 504 | const slugs = clients.map((c) => String(c.slug)); | ||
| 505 | expect(slugs).toContain(String(scopedA)); | ||
| 506 | expect(slugs).toContain(String(scopedB)); | ||
| 507 | |||
| 508 | // A SECOND resource-less request now reuses the resource-less client. | ||
| 509 | yield* server.clearRequests; | ||
| 510 | const reused = yield* executor.oauth.registerDynamicClient({ | ||
| 511 | owner: "org", | ||
| 512 | slug: OAuthClientSlug.make("resource-less-again"), | ||
| 513 | issuer: probe.issuer, | ||
| 514 | registrationEndpoint: probe.registrationEndpoint!, | ||
| 515 | authorizationUrl: probe.authorizationUrl, | ||
| 516 | tokenUrl: probe.tokenUrl, | ||
| 517 | resource: null, | ||
| 518 | scopes: ["read"], | ||
| 519 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | ||
| 520 | clientName: "Resource-less again", | ||
| 521 | redirectUri: FLOW_REDIRECT_URI, | ||
| 522 | originIntegration: IntegrationSlug.make("other"), | ||
| 523 | }); | ||
| 524 | expect(String(reused)).toBe(String(resourceLess)); | ||
| 525 | expect(registerRequestCount(yield* server.requests)).toBe(0); | ||
| 526 | }), | ||
| 527 | ), | ||
| 528 | ); | ||
| 529 | |||
| 148 | // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only | 530 | // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only |
| 149 | // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN | 531 | // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN |
| 150 | // origin trips `invalid_redirect_uri`. The failure must explain the loopback | 532 | // origin trips `invalid_redirect_uri`. The failure must explain the loopback |
| @@ -70,6 +70,7 @@ import { · 69 unmodified lines | |||
| 70 | type OAuthEndpointUrlPolicy, | 70 | type OAuthEndpointUrlPolicy, |
| 71 | } from "./oauth-helpers"; | 71 | } from "./oauth-helpers"; |
| 72 | import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth"; | 72 | import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth"; |
| 73 | import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc"; | ||
| 73 | 74 | ||
| 74 | /** Connection-minting input for the OAuth flow — extends a connection create | 75 | /** Connection-minting input for the OAuth flow — extends a connection create |
| 75 | * with the OAuth lifecycle fields (client slug, refresh material, expiry, | 76 | * with the OAuth lifecycle fields (client slug, refresh material, expiry, |
| @@ -239,6 +240,68 @@ const clientOwnerFromPayload = (payload: unknown): Owner | null => { · 163 unmodified lines | |||
| 239 | const parseGrant = (grant: unknown): OAuthGrant | null => | 240 | const parseGrant = (grant: unknown): OAuthGrant | null => |
| 240 | grant === "client_credentials" || grant === "authorization_code" ? grant : null; | 241 | grant === "client_credentials" || grant === "authorization_code" ? grant : null; |
| 241 | 242 | ||
| 243 | const canonicalDcrIssuer = ( | ||
| 244 | issuer: string | null | undefined, | ||
| 245 | registrationEndpoint: string, | ||
| 246 | ): string | null => { | ||
| 247 | const discovered = canonicalIssuerUrl(issuer); | ||
| 248 | if (discovered !== null) return discovered; | ||
| 249 | const endpoint = parseUrl(registrationEndpoint); | ||
| 250 | return endpoint === null ? null : endpoint.origin; | ||
| 251 | }; | ||
| 252 | |||
| 253 | const issuerOrigin = (issuer: string): string | null => parseUrl(issuer)?.origin ?? null; | ||
| 254 | |||
| 255 | const issuerIsOriginOnly = (issuer: string): boolean => issuerOrigin(issuer) === issuer; | ||
| 256 | |||
| 257 | const dcrIssuerMatches = (rowIssuer: string, inputIssuer: string | null): boolean => | ||
| 258 | inputIssuer !== null && | ||
| 259 | (rowIssuer === inputIssuer || | ||
| 260 | (issuerIsOriginOnly(inputIssuer) && issuerOrigin(rowIssuer) === inputIssuer)); | ||
| 261 | |||
| 262 | const slugifyOAuthKey = (value: string): string => | ||
| 263 | value | ||
| 264 | .toLowerCase() | ||
| 265 | .replace(/[^a-z0-9]+/g, "-") | ||
| 266 | .replace(/^-+|-+$/g, ""); | ||
| 267 | |||
| 268 | const shortStableHash = (value: string): string => { | ||
| 269 | let hash = 0x811c9dc5; | ||
| 270 | for (let i = 0; i < value.length; i += 1) { | ||
| 271 | hash ^= value.charCodeAt(i); | ||
| 272 | hash = Math.imul(hash, 0x01000193); | ||
| 273 | } | ||
| 274 | return (hash >>> 0).toString(36); | ||
| 275 | }; | ||
| 276 | |||
| 277 | const dcrClientSlug = ( | ||
| 278 | issuer: string | null, | ||
| 279 | resource: string | null, | ||
| 280 | fallback: OAuthClientSlug, | ||
| 281 | ): OAuthClientSlug => { | ||
| 282 | if (issuer === null) return fallback; | ||
| 283 | const issuerHost = hostOfUrl(issuer); | ||
| 284 | if (issuerHost === null) return fallback; | ||
| 285 | const base = `dcr-${slugifyOAuthKey(issuerHost) || "authorization-server"}`; | ||
| 286 | if (resource === null) return OAuthClientSlug.make(base); | ||
| 287 | const resourceUrl = parseUrl(resource); | ||
| 288 | const resourceSource = | ||
| 289 | resourceUrl === null ? resource : `${resourceUrl.host}${resourceUrl.pathname}`; | ||
| 290 | const resourcePart = slugifyOAuthKey(resourceSource).slice(0, 60) || "resource"; | ||
| 291 | return OAuthClientSlug.make(`${base}-${resourcePart}-${shortStableHash(resource)}`.slice(0, 240)); | ||
| 292 | }; | ||
| 293 | |||
| 294 | /** Dedupe a freshly-minted DCR slug against slugs already held by an owner's | ||
| 295 | * DCR candidates, appending `-2`, `-3`, … so a new client never collides with | ||
| 296 | * (and clobbers, via createClient's delete-then-create) an existing one. */ | ||
| 297 | const uniqueDcrSlug = (slug: OAuthClientSlug, taken: ReadonlySet<string>): OAuthClientSlug => { | ||
| 298 | const base = String(slug); | ||
| 299 | if (!taken.has(base)) return slug; | ||
| 300 | let suffix = 2; | ||
| 301 | while (taken.has(`${base}-${suffix}`)) suffix += 1; | ||
| 302 | return OAuthClientSlug.make(`${base}-${suffix}`); | ||
| 303 | }; | ||
| 304 | |||
| 242 | const parseOAuthClientOrigin = (row: { | 305 | const parseOAuthClientOrigin = (row: { |
| 243 | readonly slug?: unknown; | 306 | readonly slug?: unknown; |
| 244 | readonly grant?: unknown; | 307 | readonly grant?: unknown; |
| @@ -246,26 +309,27 @@ const parseOAuthClientOrigin = (row: { · 1 unmodified line | |||
| 246 | readonly origin_kind?: unknown; | 309 | readonly origin_kind?: unknown; |
| 247 | readonly origin_integration?: unknown; | 310 | readonly origin_integration?: unknown; |
| 248 | }): OAuthClientOrigin => { | 311 | }): OAuthClientOrigin => { |
| 249 | if (row.origin_kind === "dynamic_client_registration") { | 312 | // Shared DCR classification (explicit origin_kind OR the legacy MCP-shaped |
| 313 | // heuristic) lives in `oauth-gc` so the runtime and the GC/backfill | ||
| 314 | // migrations agree exactly on what counts as a DCR row. | ||
| 315 | if (!isDcrClassifiedRow(row)) { | ||
| 250 | return { | 316 | return { |
| 251 | kind: "dynamic_client_registration", | 317 | kind: "manual", |
| 252 | integration: | 318 | integration: |
| 253 | row.origin_integration == null | 319 | row.origin_integration == null |
| 254 | ? null | 320 | ? null |
| 255 | : IntegrationSlug.make(String(row.origin_integration)), | 321 | : IntegrationSlug.make(String(row.origin_integration)), |
| 256 | }; | 322 | }; |
| 257 | } | 323 | } |
| 258 | const slug = row.slug == null ? "" : String(row.slug); | 324 | // An explicit-origin DCR row carries its requesting integration; a |
| 259 | const resource = row.resource == null ? "" : String(row.resource); | 325 | // heuristic-classified legacy row (null origin_kind) has none. |
| 260 | if ( | 326 | return { |
| 261 | row.origin_kind == null && | 327 | kind: "dynamic_client_registration", |
| 262 | row.grant === "authorization_code" && | 328 | integration: |
| 263 | /(^|[-_])mcp($|[-_])/.test(slug) && | 329 | row.origin_kind === "dynamic_client_registration" && row.origin_integration != null |
| 264 | /(^|\/)mcp($|[/?#])/.test(resource) | 330 | ? IntegrationSlug.make(String(row.origin_integration)) |
| 265 | ) { | 331 | : null, |
| 266 | return { kind: "dynamic_client_registration", integration: null }; | 332 | }; |
| 267 | } | ||
| 268 | return { kind: "manual" }; | ||
| 269 | }; | 333 | }; |
| 270 | 334 | ||
| 271 | interface LoadedOAuthClient { | 335 | interface LoadedOAuthClient { |
| @@ -512,11 +576,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 240 unmodified lines | |||
| 512 | client_secret_item_id: clientSecretItemIdValue, | 576 | client_secret_item_id: clientSecretItemIdValue, |
| 513 | resource: input.resource ?? null, | 577 | resource: input.resource ?? null, |
| 514 | origin_kind: input.origin?.kind ?? "manual", | 578 | origin_kind: input.origin?.kind ?? "manual", |
| 579 | // Recorded intent, kept for BOTH origins: a manual app registered from | ||
| 580 | // an integration's dialog stamps its integration so the picker can | ||
| 581 | // match it exactly, the same way a DCR client records the integration | ||
| 582 | // that requested it. | ||
| 515 | origin_integration: | 583 | origin_integration: |
| 584 | input.origin?.integration == null ? null : String(input.origin.integration), | ||
| 585 | origin_issuer: | ||
| 516 | input.origin?.kind === "dynamic_client_registration" | 586 | input.origin?.kind === "dynamic_client_registration" |
| 517 | ? input.origin.integration == null | 587 | ? (canonicalIssuerUrl(input.originIssuer) ?? null) |
| 518 | ? null | ||
| 519 | : String(input.origin.integration) | ||
| 520 | : null, | 588 | : null, |
| 521 | created_at: now, | 589 | created_at: now, |
| 522 | }), | 590 | }), |
| @@ -578,10 +646,128 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines | |||
| 578 | ? "none" | 646 | ? "none" |
| 579 | : "client_secret_post"; | 647 | : "client_secret_post"; |
| 580 | 648 | ||
| 649 | type DcrReuseCandidate = { | ||
| 650 | readonly slug: OAuthClientSlug; | ||
| 651 | readonly resource: string | null; | ||
| 652 | }; | ||
| 653 | |||
| 654 | // `oauth_client.created_at` is a date column that surfaces as a Date, an ISO | ||
| 655 | // string, or an epoch number depending on the storage backend. Normalize to | ||
| 656 | // epoch ms for deterministic oldest-first ordering; an unparseable/missing | ||
| 657 | // value sorts as 0 (oldest), so slug then breaks the tie stably. | ||
| 658 | const candidateCreatedAt = (value: unknown): number => { | ||
| 659 | if (value instanceof Date) { | ||
| 660 | const ms = value.getTime(); | ||
| 661 | return Number.isFinite(ms) ? ms : 0; | ||
| 662 | } | ||
| 663 | if (typeof value === "number") return Number.isFinite(value) ? value : 0; | ||
| 664 | if (typeof value === "string") { | ||
| 665 | const ms = Date.parse(value); | ||
| 666 | return Number.isFinite(ms) ? ms : 0; | ||
| 667 | } | ||
| 668 | return 0; | ||
| 669 | }; | ||
| 670 | |||
| 671 | const dcrCandidatesForIssuer = ( | ||
| 672 | owner: Owner, | ||
| 673 | issuer: string | null, | ||
| 674 | ): Effect.Effect<readonly DcrReuseCandidate[], StorageFailure> => | ||
| 675 | deps.fuma | ||
| 676 | .use("oauth_client.findMany", (db) => | ||
| 677 | looseDb(db).findMany("oauth_client", { | ||
| 678 | where: (b: any) => b("owner", "=", owner), | ||
| 679 | }), | ||
| 680 | ) | ||
| 681 | .pipe( | ||
| 682 | Effect.map((rows) => { | ||
| 683 | const matches = rows.flatMap( | ||
| 684 | (row): readonly (DcrReuseCandidate & { readonly createdAt: number })[] => { | ||
| 685 | if (parseOAuthClientOrigin(row).kind !== "dynamic_client_registration") return []; | ||
| 686 | // A candidate matches only via a non-null, canonicalized stored | ||
| 687 | // issuer. The GC migration backfills origin_issuer on every | ||
| 688 | // surviving DCR row, so post-migration a null-issuer row is a | ||
| 689 | // transient (unmigrated) row; skipping it just mints one duplicate | ||
| 690 | // the migration then GCs, rather than reusing on a fuzzy token-host | ||
| 691 | // guess. | ||
| 692 | const rowIssuer = | ||
| 693 | row.origin_issuer == null ? null : canonicalIssuerUrl(String(row.origin_issuer)); | ||
| 694 | const issuerMatches = rowIssuer !== null && dcrIssuerMatches(rowIssuer, issuer); | ||
| 695 | if (!issuerMatches) return []; | ||
| 696 | return [ | ||
| 697 | { | ||
| 698 | slug: OAuthClientSlug.make(String(row.slug)), | ||
| 699 | resource: row.resource == null ? null : String(row.resource), | ||
| 700 | createdAt: candidateCreatedAt(row.created_at), | ||
| 701 | }, | ||
| 702 | ]; | ||
| 703 | }, | ||
| 704 | ); | ||
| 705 | // Deterministic reuse order: oldest first, slug as a stable tiebreak | ||
| 706 | // when timestamps collide or are missing. Without this, which of | ||
| 707 | // several live duplicates sharing an (owner, issuer) gets reused is | ||
| 708 | // whatever order the storage backend returned rows in — the reuse | ||
| 709 | // pick must be stable across boots and backends. | ||
| 710 | return [...matches] | ||
| 711 | .sort( | ||
| 712 | (a, b) => | ||
| 713 | a.createdAt - b.createdAt || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0), | ||
| 714 | ) | ||
| 715 | .map(({ slug, resource }): DcrReuseCandidate => ({ slug, resource })); | ||
| 716 | }), | ||
| 717 | ); | ||
| 718 | |||
| 719 | const decideDcrClientReuse = ( | ||
| 720 | input: RegisterDynamicClientInput, | ||
| 721 | issuer: string | null, | ||
| 722 | ): Effect.Effect< | ||
| 723 | { | ||
| 724 | readonly existingSlug: OAuthClientSlug | null; | ||
| 725 | readonly registrationSlug: OAuthClientSlug; | ||
| 726 | }, | ||
| 727 | StorageFailure | ||
| 728 | > => | ||
| 729 | Effect.gen(function* () { | ||
| 730 | const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer); | ||
| 731 | const resource = input.resource ?? null; | ||
| 732 | if (resource !== null) { | ||
| 733 | const matchingResource = candidates.find((client) => client.resource === resource); | ||
| 734 | if (matchingResource) { | ||
| 735 | return { existingSlug: matchingResource.slug, registrationSlug: matchingResource.slug }; | ||
| 736 | } | ||
| 737 | const slug = dcrClientSlug(issuer, candidates.length > 0 ? resource : null, input.slug); | ||
| 738 | return { | ||
| 739 | existingSlug: null, | ||
| 740 | registrationSlug: slug, | ||
| 741 | }; | ||
| 742 | } | ||
| 743 | |||
| 744 | // Resource-less request: only reuse a resource-LESS candidate. A client | ||
| 745 | // minted for a specific RFC 8707 resource must NOT be reused for a | ||
| 746 | // resource-less flow (its tokens are bound to that resource), so when only | ||
| 747 | // resource-scoped candidates exist we register a fresh resource-less client | ||
| 748 | // rather than silently borrowing one (the old `?? candidates[0]` bug). | ||
| 749 | const reusable = candidates.find((client) => client.resource === null); | ||
| 750 | if (reusable) return { existingSlug: reusable.slug, registrationSlug: reusable.slug }; | ||
| 751 | // Fresh resource-less client. Its slug is the bare `dcr-<host>` base, but | ||
| 752 | // the FIRST resource-scoped registration for an issuer also takes that base | ||
| 753 | // (dcrClientSlug only suffixes once candidates exist). `createClient` | ||
| 754 | // deletes any row with a colliding (owner, slug) first, so reusing the base | ||
| 755 | // here would silently clobber that resource-scoped client. Dedupe against | ||
| 756 | // the existing candidate slugs so the resource-less client keeps its own row. | ||
| 757 | const takenSlugs = new Set(candidates.map((client) => String(client.slug))); | ||
| 758 | const slug = uniqueDcrSlug(dcrClientSlug(issuer, null, input.slug), takenSlugs); | ||
| 759 | return { existingSlug: null, registrationSlug: slug }; | ||
| 760 | }); | ||
| 761 | |||
| 581 | const registerDynamicClient = ( | 762 | const registerDynamicClient = ( |
| 582 | input: RegisterDynamicClientInput, | 763 | input: RegisterDynamicClientInput, |
| 583 | ): Effect.Effect<OAuthClientSlug, OAuthRegisterDynamicError | StorageFailure> => | 764 | ): Effect.Effect<OAuthClientSlug, OAuthRegisterDynamicError | StorageFailure> => |
| 584 | Effect.gen(function* () { | 765 | Effect.gen(function* () { |
| 766 | const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); | ||
| 767 | const reuse = yield* decideDcrClientReuse(input, issuer); | ||
| 768 | if (reuse.existingSlug !== null) return reuse.existingSlug; | ||
| 769 | |||
| 770 | const slug = reuse.registrationSlug; | ||
| 585 | const flowRedirectUri = input.redirectUri ?? redirectUri; | 771 | const flowRedirectUri = input.redirectUri ?? redirectUri; |
| 586 | // DCR registers our callback as the client's redirect_uri — fail loudly | 772 | // DCR registers our callback as the client's redirect_uri — fail loudly |
| 587 | // if the executor has none rather than registering a localhost URL. | 773 | // if the executor has none rather than registering a localhost URL. |
| @@ -633,7 +819,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 45 unmodified lines | |||
| 633 | // stored client carries no scope set (the integration drives requests). | 819 | // stored client carries no scope set (the integration drives requests). |
| 634 | yield* createClient({ | 820 | yield* createClient({ |
| 635 | owner: input.owner, | 821 | owner: input.owner, |
| 636 | slug: input.slug, | 822 | slug, |
| 637 | authorizationUrl: input.authorizationUrl, | 823 | authorizationUrl: input.authorizationUrl, |
| 638 | tokenUrl: input.tokenUrl, | 824 | tokenUrl: input.tokenUrl, |
| 639 | resource: input.resource ?? null, | 825 | resource: input.resource ?? null, |
| @@ -644,8 +830,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 4 unmodified lines | |||
| 644 | kind: "dynamic_client_registration", | 830 | kind: "dynamic_client_registration", |
| 645 | integration: input.originIntegration ?? null, | 831 | integration: input.originIntegration ?? null, |
| 646 | }, | 832 | }, |
| 833 | originIssuer: issuer, | ||
| 647 | }); | 834 | }); |
| 648 | return input.slug; | 835 | return slug; |
| 649 | }); | 836 | }); |
| 650 | 837 | ||
| 651 | // ----------------------------------------------------------------------- | 838 | // ----------------------------------------------------------------------- |
| @@ -1158,6 +1345,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 506 unmodified lines | |||
| 1158 | }); | 1345 | }); |
| 1159 | } | 1346 | } |
| 1160 | return { | 1347 | return { |
| 1348 | issuer: as.metadata.issuer, | ||
| 1161 | authorizationUrl: as.metadata.authorization_endpoint, | 1349 | authorizationUrl: as.metadata.authorization_endpoint, |
| 1162 | tokenUrl: as.metadata.token_endpoint, | 1350 | tokenUrl: as.metadata.token_endpoint, |
| 1163 | resource: resource?.metadata.resource ?? null, | 1351 | resource: resource?.metadata.resource ?? null, |
| @@ -127,6 +127,7 @@ export { · 126 unmodified lines | |||
| 127 | type OAuthGrant, | 127 | type OAuthGrant, |
| 128 | type OAuthAuthentication, | 128 | type OAuthAuthentication, |
| 129 | type OAuthClient, | 129 | type OAuthClient, |
| 130 | type OAuthClientOrigin, | ||
| 130 | type OAuthClientSummary, | 131 | type OAuthClientSummary, |
| 131 | type CreateOAuthClientInput, | 132 | type CreateOAuthClientInput, |
| 132 | type RegisterDynamicClientInput, | 133 | type RegisterDynamicClientInput, |
| @@ -0,0 +1,351 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Fiber } from "effect"; | ||
| 3 | |||
| 4 | import { collectTables } from "./executor"; | ||
| 5 | import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db"; | ||
| 6 | import { runSqliteOAuthClientGcMigration } from "./sqlite-oauth-client-gc-migration"; | ||
| 7 | |||
| 8 | // Integration coverage for the local libSQL GC + backfill migration against a | ||
| 9 | // real SQLite schema (issue #1120, Part C). Complements the pure decision-matrix | ||
| 10 | // tests in oauth-gc.test.ts by exercising the actual SQL: seeded rows in, the | ||
| 11 | // migration runs, surviving rows asserted. | ||
| 12 | |||
| 13 | const TENANT = "t1"; | ||
| 14 | const OWNER = "org"; | ||
| 15 | const SUBJECT = "s1"; | ||
| 16 | |||
| 17 | const withDb = <A>(body: (db: SqliteTestFumaDb) => Promise<A>): Promise<A> => | ||
| 18 | Effect.runPromise( | ||
| 19 | Effect.acquireUseRelease( | ||
| 20 | Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })), | ||
| 21 | (db) => Effect.promise(() => body(db)), | ||
| 22 | (db) => Effect.promise(() => db.close()), | ||
| 23 | ), | ||
| 24 | ); | ||
| 25 | |||
| 26 | const insertOAuthClient = ( | ||
| 27 | db: SqliteTestFumaDb, | ||
| 28 | row: { | ||
| 29 | readonly slug: string; | ||
| 30 | readonly tokenUrl: string; | ||
| 31 | readonly grant?: string; | ||
| 32 | readonly resource?: string | null; | ||
| 33 | readonly originKind?: string | null; | ||
| 34 | readonly originIssuer?: string | null; | ||
| 35 | }, | ||
| 36 | ): Promise<unknown> => | ||
| 37 | db.client.execute({ | ||
| 38 | sql: `INSERT INTO oauth_client | ||
| 39 | (row_id, tenant, owner, subject, slug, authorization_url, token_url, "grant", | ||
| 40 | client_id, resource, origin_kind, origin_issuer, created_at) | ||
| 41 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | ||
| 42 | args: [ | ||
| 43 | `oc_${row.slug}`, | ||
| 44 | TENANT, | ||
| 45 | OWNER, | ||
| 46 | SUBJECT, | ||
| 47 | row.slug, | ||
| 48 | "https://as.example/authorize", | ||
| 49 | row.tokenUrl, | ||
| 50 | row.grant ?? "authorization_code", | ||
| 51 | `client-${row.slug}`, | ||
| 52 | row.resource ?? null, | ||
| 53 | row.originKind ?? null, | ||
| 54 | row.originIssuer ?? null, | ||
| 55 | Date.now(), | ||
| 56 | ], | ||
| 57 | }); | ||
| 58 | |||
| 59 | const insertConnection = ( | ||
| 60 | db: SqliteTestFumaDb, | ||
| 61 | connectionName: string, | ||
| 62 | clientSlug: string, | ||
| 63 | ): Promise<unknown> => | ||
| 64 | db.client.execute({ | ||
| 65 | sql: `INSERT INTO connection | ||
| 66 | (row_id, tenant, owner, subject, integration, name, template, provider, item_ids, | ||
| 67 | oauth_client, oauth_client_owner, created_at, updated_at) | ||
| 68 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | ||
| 69 | args: [ | ||
| 70 | `cn_${connectionName}`, | ||
| 71 | TENANT, | ||
| 72 | OWNER, | ||
| 73 | SUBJECT, | ||
| 74 | "acme", | ||
| 75 | connectionName, | ||
| 76 | "oauth", | ||
| 77 | "memory", | ||
| 78 | "{}", | ||
| 79 | clientSlug, | ||
| 80 | OWNER, | ||
| 81 | Date.now(), | ||
| 82 | Date.now(), | ||
| 83 | ], | ||
| 84 | }); | ||
| 85 | |||
| 86 | const slugs = async (db: SqliteTestFumaDb): Promise<readonly string[]> => { | ||
| 87 | const result = await db.client.execute("SELECT slug FROM oauth_client ORDER BY slug"); | ||
| 88 | return result.rows.map((r) => String(r.slug)); | ||
| 89 | }; | ||
| 90 | |||
| 91 | const issuerOf = async (db: SqliteTestFumaDb, slug: string): Promise<string | null> => { | ||
| 92 | const result = await db.client.execute({ | ||
| 93 | sql: "SELECT origin_issuer FROM oauth_client WHERE slug = ?", | ||
| 94 | args: [slug], | ||
| 95 | }); | ||
| 96 | const value = result.rows[0]?.origin_issuer; | ||
| 97 | return value == null ? null : String(value); | ||
| 98 | }; | ||
| 99 | |||
| 100 | const originKindOf = async (db: SqliteTestFumaDb, slug: string): Promise<string | null> => { | ||
| 101 | const result = await db.client.execute({ | ||
| 102 | sql: "SELECT origin_kind FROM oauth_client WHERE slug = ?", | ||
| 103 | args: [slug], | ||
| 104 | }); | ||
| 105 | const value = result.rows[0]?.origin_kind; | ||
| 106 | return value == null ? null : String(value); | ||
| 107 | }; | ||
| 108 | |||
| 109 | describe("runSqliteOAuthClientGcMigration", () => { | ||
| 110 | it.effect("deletes orphaned DCR rows, keeps manual + referenced rows, backfills survivors", () => | ||
| 111 | Effect.gen(function* () { | ||
| 112 | const outcome = yield* Effect.promise(() => | ||
| 113 | withDb(async (db) => { | ||
| 114 | // Orphaned explicit DCR → delete. | ||
| 115 | await insertOAuthClient(db, { | ||
| 116 | slug: "dcr-cloudflare-com", | ||
| 117 | tokenUrl: "https://oauth.cloudflare.com/token", | ||
| 118 | resource: "https://cloudflare.example/mcp", | ||
| 119 | originKind: "dynamic_client_registration", | ||
| 120 | originIssuer: "https://cloudflare.com", | ||
| 121 | }); | ||
| 122 | // Orphaned legacy heuristic DCR (null origin) → delete. | ||
| 123 | await insertOAuthClient(db, { | ||
| 124 | slug: "cloudflare-mcp-2", | ||
| 125 | tokenUrl: "https://oauth.cloudflare.com/token", | ||
| 126 | resource: "https://cloudflare.example/mcp", | ||
| 127 | originKind: null, | ||
| 128 | }); | ||
| 129 | // Referenced legacy DCR (has a connection) → keep + backfill issuer. | ||
| 130 | await insertOAuthClient(db, { | ||
| 131 | slug: "cloudflare-mcp", | ||
| 132 | tokenUrl: "https://oauth.cloudflare.com/token", | ||
| 133 | resource: "https://cloudflare.example/mcp", | ||
| 134 | originKind: null, | ||
| 135 | originIssuer: null, | ||
| 136 | }); | ||
| 137 | await insertConnection(db, "cf-main", "cloudflare-mcp"); | ||
| 138 | // Orphaned manual app → keep (never GC a hand-registered app). | ||
| 139 | await insertOAuthClient(db, { | ||
| 140 | slug: "my-github-app", | ||
| 141 | tokenUrl: "https://github.com/login/oauth/access_token", | ||
| 142 | resource: null, | ||
| 143 | originKind: "manual", | ||
| 144 | }); | ||
| 145 | |||
| 146 | const applied = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client)); | ||
| 147 | const surviving = await slugs(db); | ||
| 148 | const backfilled = await issuerOf(db, "cloudflare-mcp"); | ||
| 149 | const survivorKind = await originKindOf(db, "cloudflare-mcp"); | ||
| 150 | const manualKind = await originKindOf(db, "my-github-app"); | ||
| 151 | const manualIssuer = await issuerOf(db, "my-github-app"); | ||
| 152 | return { | ||
| 153 | applied, | ||
| 154 | surviving, | ||
| 155 | backfilled, | ||
| 156 | survivorKind, | ||
| 157 | manualKind, | ||
| 158 | manualIssuer, | ||
| 159 | }; | ||
| 160 | }), | ||
| 161 | ); | ||
| 162 | |||
| 163 | expect(outcome.applied).toEqual({ | ||
| 164 | deleted: 2, | ||
| 165 | backfilled: 1, | ||
| 166 | stampedDcr: 1, | ||
| 167 | stampedManual: 0, | ||
| 168 | }); | ||
| 169 | // Both orphaned DCR rows gone; the referenced DCR row and the manual app | ||
| 170 | // survive. | ||
| 171 | expect(outcome.surviving).toEqual(["cloudflare-mcp", "my-github-app"]); | ||
| 172 | // The surviving legacy DCR row got its issuer backfilled from token_url's | ||
| 173 | // registrable origin, so the per-AS reuse lookup can now key on it, and it | ||
| 174 | // is stamped as an explicit DCR row. | ||
| 175 | expect(outcome.backfilled).toBe("https://cloudflare.com"); | ||
| 176 | expect(outcome.survivorKind).toBe("dynamic_client_registration"); | ||
| 177 | // The manual app was already explicitly stamped, so its stamp is untouched | ||
| 178 | // and it never gets an origin_issuer. | ||
| 179 | expect(outcome.manualKind).toBe("manual"); | ||
| 180 | expect(outcome.manualIssuer).toBeNull(); | ||
| 181 | }), | ||
| 182 | ); | ||
| 183 | |||
| 184 | it.effect("stamps a legacy null-origin manual survivor as manual with no issuer", () => | ||
| 185 | Effect.gen(function* () { | ||
| 186 | const outcome = yield* Effect.promise(() => | ||
| 187 | withDb(async (db) => { | ||
| 188 | // Legacy manual row: null origin_kind, auth-code, but NO resource, so | ||
| 189 | // the classifier keeps it manual. It must survive, get an explicit | ||
| 190 | // `manual` stamp, and NOT receive an origin_issuer. | ||
| 191 | await insertOAuthClient(db, { | ||
| 192 | slug: "legacy-manual", | ||
| 193 | tokenUrl: "https://github.com/login/oauth/access_token", | ||
| 194 | resource: null, | ||
| 195 | originKind: null, | ||
| 196 | originIssuer: null, | ||
| 197 | }); | ||
| 198 | |||
| 199 | const applied = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client)); | ||
| 200 | const surviving = await slugs(db); | ||
| 201 | const kind = await originKindOf(db, "legacy-manual"); | ||
| 202 | const issuer = await issuerOf(db, "legacy-manual"); | ||
| 203 | return { applied, surviving, kind, issuer }; | ||
| 204 | }), | ||
| 205 | ); | ||
| 206 | |||
| 207 | expect(outcome.applied).toEqual({ | ||
| 208 | deleted: 0, | ||
| 209 | backfilled: 0, | ||
| 210 | stampedDcr: 0, | ||
| 211 | stampedManual: 1, | ||
| 212 | }); | ||
| 213 | expect(outcome.surviving).toEqual(["legacy-manual"]); | ||
| 214 | expect(outcome.kind).toBe("manual"); | ||
| 215 | expect(outcome.issuer).toBeNull(); | ||
| 216 | }), | ||
| 217 | ); | ||
| 218 | |||
| 219 | it.effect("is idempotent: a second run deletes nothing more and re-backfills nothing", () => | ||
| 220 | Effect.gen(function* () { | ||
| 221 | const outcome = yield* Effect.promise(() => | ||
| 222 | withDb(async (db) => { | ||
| 223 | await insertOAuthClient(db, { | ||
| 224 | slug: "cloudflare-mcp-2", | ||
| 225 | tokenUrl: "https://oauth.cloudflare.com/token", | ||
| 226 | resource: "https://cloudflare.example/mcp", | ||
| 227 | originKind: null, | ||
| 228 | }); | ||
| 229 | await insertOAuthClient(db, { | ||
| 230 | slug: "cloudflare-mcp", | ||
| 231 | tokenUrl: "https://oauth.cloudflare.com/token", | ||
| 232 | resource: "https://cloudflare.example/mcp", | ||
| 233 | originKind: null, | ||
| 234 | originIssuer: null, | ||
| 235 | }); | ||
| 236 | await insertConnection(db, "cf-main", "cloudflare-mcp"); | ||
| 237 | |||
| 238 | const first = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client)); | ||
| 239 | const afterFirst = await slugs(db); | ||
| 240 | const second = await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client)); | ||
| 241 | const afterSecond = await slugs(db); | ||
| 242 | return { first, afterFirst, second, afterSecond }; | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | |||
| 246 | expect(outcome.first).toEqual({ | ||
| 247 | deleted: 1, | ||
| 248 | backfilled: 1, | ||
| 249 | stampedDcr: 1, | ||
| 250 | stampedManual: 0, | ||
| 251 | }); | ||
| 252 | // Second pass is a no-op: the orphan is already gone, the survivor's issuer | ||
| 253 | // is set, and its origin_kind is stamped (so it is no longer a null-origin | ||
| 254 | // candidate for any of delete / backfill / stamp). | ||
| 255 | expect(outcome.second).toEqual({ | ||
| 256 | deleted: 0, | ||
| 257 | backfilled: 0, | ||
| 258 | stampedDcr: 0, | ||
| 259 | stampedManual: 0, | ||
| 260 | }); | ||
| 261 | expect(outcome.afterSecond).toEqual(outcome.afterFirst); | ||
| 262 | expect(outcome.afterSecond).toEqual(["cloudflare-mcp"]); | ||
| 263 | }), | ||
| 264 | ); | ||
| 265 | |||
| 266 | it.effect("never deletes a manual app even when it is orphaned and MCP-shaped", () => | ||
| 267 | Effect.gen(function* () { | ||
| 268 | const surviving = yield* Effect.promise(() => | ||
| 269 | withDb(async (db) => { | ||
| 270 | // Explicit manual origin_kind must shield even an MCP-shaped slug from | ||
| 271 | // the heuristic. | ||
| 272 | await insertOAuthClient(db, { | ||
| 273 | slug: "cloudflare-mcp", | ||
| 274 | tokenUrl: "https://oauth.cloudflare.com/token", | ||
| 275 | resource: "https://cloudflare.example/mcp", | ||
| 276 | originKind: "manual", | ||
| 277 | }); | ||
| 278 | await Effect.runPromise(runSqliteOAuthClientGcMigration(db.client)); | ||
| 279 | return slugs(db); | ||
| 280 | }), | ||
| 281 | ); | ||
| 282 | expect(surviving).toEqual(["cloudflare-mcp"]); | ||
| 283 | }), | ||
| 284 | ); | ||
| 285 | |||
| 286 | it.effect("no-op on an empty oauth_client table", () => | ||
| 287 | Effect.gen(function* () { | ||
| 288 | const applied = yield* Effect.promise(() => | ||
| 289 | withDb((db) => Effect.runPromise(runSqliteOAuthClientGcMigration(db.client))), | ||
| 290 | ); | ||
| 291 | expect(applied).toEqual({ deleted: 0, backfilled: 0, stampedDcr: 0, stampedManual: 0 }); | ||
| 292 | }), | ||
| 293 | ); | ||
| 294 | |||
| 295 | it.effect("rolls back the transaction when the migration fiber is interrupted mid-run", () => | ||
| 296 | Effect.gen(function* () { | ||
| 297 | const surviving = yield* Effect.promise(() => | ||
| 298 | withDb(async (db) => { | ||
| 299 | // Orphaned DCR row: without rollback-on-interrupt, an interruption | ||
| 300 | // landing after its DELETE (but before COMMIT) can still leave the | ||
| 301 | // transaction committed by a later statement, or the DELETE alone | ||
| 302 | // holding an open transaction — either way the row's fate becomes | ||
| 303 | // implementation-dependent instead of "the whole run never happened." | ||
| 304 | await insertOAuthClient(db, { | ||
| 305 | slug: "dcr-cloudflare-com", | ||
| 306 | tokenUrl: "https://oauth.cloudflare.com/token", | ||
| 307 | resource: "https://cloudflare.example/mcp", | ||
| 308 | originKind: "dynamic_client_registration", | ||
| 309 | originIssuer: "https://cloudflare.com", | ||
| 310 | }); | ||
| 311 | |||
| 312 | // Interrupt the migration's fiber right after its DELETE statement | ||
| 313 | // executes (i.e. mid-transaction, well before COMMIT), by wrapping | ||
| 314 | // the client so the DELETE's returned promise resolves only after | ||
| 315 | // the outer fiber has been asked to interrupt. | ||
| 316 | const realExecute = db.client.execute.bind(db.client); | ||
| 317 | let releaseInterrupt: (() => void) | null = null; | ||
| 318 | const interruptSignal = new Promise<void>((resolve) => { | ||
| 319 | releaseInterrupt = resolve; | ||
| 320 | }); | ||
| 321 | const wrappedClient: typeof db.client = new Proxy(db.client, { | ||
| 322 | get(target, prop, receiver) { | ||
| 323 | if (prop !== "execute") return Reflect.get(target, prop, receiver); | ||
| 324 | return async (stmt: unknown) => { | ||
| 325 | const sql = typeof stmt === "string" ? stmt : (stmt as { sql: string }).sql; | ||
| 326 | const result = await realExecute(stmt as never); | ||
| 327 | if (typeof sql === "string" && sql.startsWith("DELETE FROM oauth_client")) { | ||
| 328 | // Let the test's interrupt fire, then hand control back so the | ||
| 329 | // fiber observes the interruption as its very next step. | ||
| 330 | releaseInterrupt?.(); | ||
| 331 | await new Promise((r) => setTimeout(r, 20)); | ||
| 332 | } | ||
| 333 | return result; | ||
| 334 | }; | ||
| 335 | }, | ||
| 336 | }); | ||
| 337 | |||
| 338 | const fiber = Effect.runFork(runSqliteOAuthClientGcMigration(wrappedClient)); | ||
| 339 | await interruptSignal; | ||
| 340 | await Effect.runPromise(Fiber.interrupt(fiber)); | ||
| 341 | |||
| 342 | return slugs(db); | ||
| 343 | }), | ||
| 344 | ); | ||
| 345 | |||
| 346 | // Rolled back: the orphaned row is exactly as it was before the run, not | ||
| 347 | // deleted (proving ROLLBACK ran) and not left half-mutated. | ||
| 348 | expect(surviving).toEqual(["dcr-cloudflare-com"]); | ||
| 349 | }), | ||
| 350 | ); | ||
| 351 | }); | ||
| @@ -0,0 +1,187 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // libSQL boot migration: GC dead DCR `oauth_client` rows + backfill the | ||
| 3 | // surviving ones' `origin_issuer` (issue #1120, Part C). | ||
| 4 | // | ||
| 5 | // This is the local/self-host arm of the cleanup (the cloud arm is a numbered | ||
| 6 | // Drizzle SQL migration). It runs once through the stamped `data_migration` | ||
| 7 | // ledger (see sqlite-data-migrations.ts). The decision matrix is NOT re-encoded | ||
| 8 | // as SQL — it reads the rows and applies the shared `classifyOAuthClientGc` / | ||
| 9 | // `isDcrClassifiedRow` predicates in-process, then issues targeted DELETE/UPDATE | ||
| 10 | // by primary key. Rewriting the DCR heuristic as SQLite LIKE/GLOB would be a | ||
| 11 | // second, drift-prone source of truth for a query that DELETES user data; the | ||
| 12 | // oauth_client table is tiny (a handful of rows per install), so reading it in | ||
| 13 | // full at boot is cheap. Every surviving row is left with an explicit | ||
| 14 | // `origin_kind` (DCR survivors → `dynamic_client_registration`, everything else | ||
| 15 | // → `manual`) so no row keeps a NULL classification. Idempotent: a second run | ||
| 16 | // finds no orphaned DCR rows, no null-issuer DCR survivors, and no NULL | ||
| 17 | // origin_kind rows, so it is a no-op (and the ledger skips it anyway once | ||
| 18 | // stamped). | ||
| 19 | // --------------------------------------------------------------------------- | ||
| 20 | |||
| 21 | import { Effect } from "effect"; | ||
| 22 | |||
| 23 | import { | ||
| 24 | DataMigrationError, | ||
| 25 | type SqliteDataMigration, | ||
| 26 | type SqliteDataMigrationClient, | ||
| 27 | } from "./sqlite-data-migrations"; | ||
| 28 | import { classifyOAuthClientGc, isDcrClassifiedRow, registrableOriginOfUrl } from "./oauth-gc"; | ||
| 29 | |||
| 30 | const MIGRATION_NAME = "2026-07-02-gc-dead-dcr-oauth-clients"; | ||
| 31 | |||
| 32 | const execute = ( | ||
| 33 | client: SqliteDataMigrationClient, | ||
| 34 | stmt: string | { readonly sql: string; readonly args: readonly unknown[] }, | ||
| 35 | ) => | ||
| 36 | Effect.tryPromise({ | ||
| 37 | try: () => client.execute(stmt), | ||
| 38 | catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), | ||
| 39 | }); | ||
| 40 | |||
| 41 | const tableExists = ( | ||
| 42 | client: SqliteDataMigrationClient, | ||
| 43 | table: string, | ||
| 44 | ): Effect.Effect<boolean, DataMigrationError> => | ||
| 45 | execute(client, { | ||
| 46 | sql: "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", | ||
| 47 | args: [table], | ||
| 48 | }).pipe(Effect.map((result) => result.rows.length > 0)); | ||
| 49 | |||
| 50 | /** | ||
| 51 | * GC + backfill the `oauth_client` table on a libSQL/SQLite database. | ||
| 52 | * | ||
| 53 | * Returns the number of rows deleted and backfilled. Transactionally wrapped | ||
| 54 | * (BEGIN … COMMIT, ROLLBACK on error) so a mid-run failure leaves the table | ||
| 55 | * untouched and the (unstamped) migration re-runs cleanly next boot. | ||
| 56 | * | ||
| 57 | * Deletion predicate (conjunctive, fail-safe): a row is deleted iff it is | ||
| 58 | * classified DCR AND zero `connection` rows reference its (owner, slug). The | ||
| 59 | * connection reference is `(oauth_client_owner, oauth_client)` within the same | ||
| 60 | * tenant. Manual apps and still-referenced DCR apps are always kept. | ||
| 61 | */ | ||
| 62 | export const runSqliteOAuthClientGcMigration = ( | ||
| 63 | client: SqliteDataMigrationClient, | ||
| 64 | ): Effect.Effect< | ||
| 65 | { | ||
| 66 | readonly deleted: number; | ||
| 67 | readonly backfilled: number; | ||
| 68 | readonly stampedDcr: number; | ||
| 69 | readonly stampedManual: number; | ||
| 70 | }, | ||
| 71 | DataMigrationError | ||
| 72 | > => | ||
| 73 | Effect.gen(function* () { | ||
| 74 | // A pre-oauth database (or a partial baseline) simply has nothing to do. | ||
| 75 | if (!(yield* tableExists(client, "oauth_client"))) | ||
| 76 | return { deleted: 0, backfilled: 0, stampedDcr: 0, stampedManual: 0 }; | ||
| 77 | const hasConnections = yield* tableExists(client, "connection"); | ||
| 78 | |||
| 79 | const rowsResult = yield* execute( | ||
| 80 | client, | ||
| 81 | "SELECT tenant, owner, slug, grant, resource, origin_kind, origin_issuer, token_url FROM oauth_client", | ||
| 82 | ); | ||
| 83 | const rows = rowsResult.rows; | ||
| 84 | |||
| 85 | // Count connections that reference each (tenant, owner, slug) in ONE grouped | ||
| 86 | // pass (not a COUNT per oauth_client row), so the GC decision is conjunctive | ||
| 87 | // without an N+1. When the connection table is absent (impossible in | ||
| 88 | // practice, but keeps this total) every DCR row counts as orphaned. | ||
| 89 | const referenceKey = (tenant: unknown, owner: unknown, slug: unknown): string => | ||
| 90 | `${String(tenant ?? "")} ${String(owner ?? "")} ${String(slug ?? "")}`; | ||
| 91 | const referenceCounts = new Map<string, number>(); | ||
| 92 | if (hasConnections) { | ||
| 93 | const countsResult = yield* execute( | ||
| 94 | client, | ||
| 95 | "SELECT tenant, oauth_client_owner, oauth_client, COUNT(*) AS count FROM connection WHERE oauth_client IS NOT NULL GROUP BY tenant, oauth_client_owner, oauth_client", | ||
| 96 | ); | ||
| 97 | for (const countRow of countsResult.rows) { | ||
| 98 | referenceCounts.set( | ||
| 99 | referenceKey(countRow.tenant, countRow.oauth_client_owner, countRow.oauth_client), | ||
| 100 | Number(countRow.count ?? 0), | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | const applyAll = Effect.gen(function* () { | ||
| 106 | let deleted = 0; | ||
| 107 | let backfilled = 0; | ||
| 108 | let stampedDcr = 0; | ||
| 109 | let stampedManual = 0; | ||
| 110 | |||
| 111 | for (const row of rows) { | ||
| 112 | const isDcr = isDcrClassifiedRow(row); | ||
| 113 | |||
| 114 | // Manual apps are never deleted or issuer-backfilled, but a legacy | ||
| 115 | // (null origin_kind) manual row still gets an explicit `manual` stamp so | ||
| 116 | // every surviving row ends this migration with a concrete classification. | ||
| 117 | if (!isDcr) { | ||
| 118 | if (row.origin_kind == null) { | ||
| 119 | yield* execute(client, { | ||
| 120 | sql: "UPDATE oauth_client SET origin_kind = 'manual' WHERE tenant = ? AND owner = ? AND slug = ?", | ||
| 121 | args: [row.tenant ?? null, row.owner ?? null, row.slug ?? null], | ||
| 122 | }); | ||
| 123 | stampedManual += 1; | ||
| 124 | } | ||
| 125 | continue; | ||
| 126 | } | ||
| 127 | |||
| 128 | const count = referenceCounts.get(referenceKey(row.tenant, row.owner, row.slug)) ?? 0; | ||
| 129 | const decision = classifyOAuthClientGc(row, count); | ||
| 130 | |||
| 131 | if (decision.action === "delete") { | ||
| 132 | yield* execute(client, { | ||
| 133 | sql: "DELETE FROM oauth_client WHERE tenant = ? AND owner = ? AND slug = ?", | ||
| 134 | args: [row.tenant ?? null, row.owner ?? null, row.slug ?? null], | ||
| 135 | }); | ||
| 136 | deleted += 1; | ||
| 137 | continue; | ||
| 138 | } | ||
| 139 | |||
| 140 | // Surviving DCR row. Stamp a legacy (null origin_kind) survivor as DCR, | ||
| 141 | // and backfill its `origin_issuer` from the registrable origin of | ||
| 142 | // token_url when it has none, so the per-AS reuse lookup keys on it and | ||
| 143 | // mints no new duplicate. Both live in one UPDATE per row. | ||
| 144 | const setClauses: string[] = []; | ||
| 145 | const setArgs: unknown[] = []; | ||
| 146 | if (row.origin_kind == null) { | ||
| 147 | setClauses.push("origin_kind = 'dynamic_client_registration'"); | ||
| 148 | stampedDcr += 1; | ||
| 149 | } | ||
| 150 | if (row.origin_issuer == null) { | ||
| 151 | const issuer = | ||
| 152 | row.token_url == null ? null : registrableOriginOfUrl(String(row.token_url)); | ||
| 153 | if (issuer !== null) { | ||
| 154 | setClauses.push("origin_issuer = ?"); | ||
| 155 | setArgs.push(issuer); | ||
| 156 | backfilled += 1; | ||
| 157 | } | ||
| 158 | } | ||
| 159 | if (setClauses.length > 0) { | ||
| 160 | yield* execute(client, { | ||
| 161 | sql: `UPDATE oauth_client SET ${setClauses.join(", ")} WHERE tenant = ? AND owner = ? AND slug = ?`, | ||
| 162 | args: [...setArgs, row.tenant ?? null, row.owner ?? null, row.slug ?? null], | ||
| 163 | }); | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | yield* execute(client, "COMMIT"); | ||
| 168 | return { deleted, backfilled, stampedDcr, stampedManual }; | ||
| 169 | }); | ||
| 170 | |||
| 171 | yield* execute(client, "BEGIN"); | ||
| 172 | return yield* applyAll.pipe( | ||
| 173 | Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)), | ||
| 174 | // `tapError` only fires on a typed failure, not on fiber interruption | ||
| 175 | // (e.g. the boot sequence timing out or the process shutting down | ||
| 176 | // mid-migration) — so without this, an interrupted run can leave the | ||
| 177 | // transaction open. Roll back explicitly on interrupt too; never on | ||
| 178 | // success (COMMIT already ran by then, so this is a no-op there). | ||
| 179 | Effect.onInterrupt(() => execute(client, "ROLLBACK").pipe(Effect.ignore)), | ||
| 180 | ); | ||
| 181 | }); | ||
| 182 | |||
| 183 | /** Ledger entry for the local/self-host boot data-migration registry. */ | ||
| 184 | export const oauthClientGcSqliteMigration: SqliteDataMigration = { | ||
| 185 | name: MIGRATION_NAME, | ||
| 186 | run: (client) => runSqliteOAuthClientGcMigration(client).pipe(Effect.asVoid), | ||
| 187 | }; | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/vite-plugin", | 2 | "name": "@executor-js/vite-plugin", |
| 3 | "version": "0.0.44", | 3 | "version": "0.0.44", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/vite-plugin", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/core/vite-plugin" | 12 | "directory": "packages/core/vite-plugin" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -37,6 +37,7 @@ · 36 unmodified lines | |||
| 37 | "@types/node": "catalog:", | 37 | "@types/node": "catalog:", |
| 38 | "bun-types": "catalog:", | 38 | "bun-types": "catalog:", |
| 39 | "typescript": "catalog:", | 39 | "typescript": "catalog:", |
| 40 | "urlpattern-polyfill": "^10.1.0", | ||
| 40 | "vitest": "catalog:" | 41 | "vitest": "catalog:" |
| 41 | } | 42 | } |
| 42 | } | 43 | } |
| @@ -0,0 +1,360 @@ | |||
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; | ||
| 2 | import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp"; | ||
| 3 | import { Effect, Option, Schema } from "effect"; | ||
| 4 | |||
| 5 | const KEEPALIVE_INTERVAL_MS = 25_000; | ||
| 6 | const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; | ||
| 7 | |||
| 8 | type FakeWebSocket = EventTarget & { | ||
| 9 | accepted: boolean; | ||
| 10 | closeCode: number | undefined; | ||
| 11 | closeReason: string | undefined; | ||
| 12 | accept: () => void; | ||
| 13 | close: (code?: number, reason?: string) => void; | ||
| 14 | }; | ||
| 15 | |||
| 16 | type FakeAgentStub = { | ||
| 17 | readonly setName: (name: string, props?: unknown) => Promise<void>; | ||
| 18 | readonly getInitializeRequest: () => Promise<unknown>; | ||
| 19 | readonly fetch: (request: Request) => Promise<{ readonly webSocket: FakeWebSocket }>; | ||
| 20 | }; | ||
| 21 | |||
| 22 | type RotationLog = { | ||
| 23 | readonly event: "sse_max_age_close"; | ||
| 24 | readonly sessionId: string; | ||
| 25 | readonly variant: "streamable-get" | "streamable-post" | "legacy-sse"; | ||
| 26 | readonly ageMs: number; | ||
| 27 | readonly pendingBytes: number; | ||
| 28 | }; | ||
| 29 | |||
| 30 | const encoder = new TextEncoder(); | ||
| 31 | const RotationLogEvent = Schema.Struct({ | ||
| 32 | ageMs: Schema.Number, | ||
| 33 | event: Schema.Literal("sse_max_age_close"), | ||
| 34 | pendingBytes: Schema.Number, | ||
| 35 | sessionId: Schema.String, | ||
| 36 | variant: Schema.Union([ | ||
| 37 | Schema.Literal("streamable-get"), | ||
| 38 | Schema.Literal("streamable-post"), | ||
| 39 | Schema.Literal("legacy-sse"), | ||
| 40 | ]), | ||
| 41 | }); | ||
| 42 | const decodeRotationLogEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RotationLogEvent)); | ||
| 43 | |||
| 44 | const flushMicrotasks = async (): Promise<void> => { | ||
| 45 | await Promise.resolve(); | ||
| 46 | await Promise.resolve(); | ||
| 47 | await Promise.resolve(); | ||
| 48 | }; | ||
| 49 | |||
| 50 | const waitFor = async (predicate: () => boolean): Promise<void> => { | ||
| 51 | for (let attempt = 0; attempt < 20; attempt += 1) { | ||
| 52 | if (predicate()) return; | ||
| 53 | await flushMicrotasks(); | ||
| 54 | } | ||
| 55 | expect(predicate()).toBe(true); | ||
| 56 | }; | ||
| 57 | |||
| 58 | const drainResponse = async (response: Response): Promise<string> => { | ||
| 59 | const decoder = new TextDecoder(); | ||
| 60 | let body = ""; | ||
| 61 | |||
| 62 | await Effect.runPromise( | ||
| 63 | Effect.ignore( | ||
| 64 | Effect.tryPromise({ | ||
| 65 | try: () => | ||
| 66 | response.body?.pipeTo( | ||
| 67 | new WritableStream<Uint8Array>({ | ||
| 68 | close: () => { | ||
| 69 | body += decoder.decode(); | ||
| 70 | }, | ||
| 71 | write: (chunk) => { | ||
| 72 | body += decoder.decode(chunk, { stream: true }); | ||
| 73 | }, | ||
| 74 | }), | ||
| 75 | ) ?? Promise.resolve(), | ||
| 76 | catch: () => undefined, | ||
| 77 | }), | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | |||
| 81 | return body; | ||
| 82 | }; | ||
| 83 | |||
| 84 | const installStallingTransformStream = () => { | ||
| 85 | let abortReason: unknown; | ||
| 86 | let writeCount = 0; | ||
| 87 | let stalledWriteStarted: (() => void) | undefined; | ||
| 88 | const stalledWrite = new Promise<void>((resolve) => { | ||
| 89 | stalledWriteStarted = resolve; | ||
| 90 | }); | ||
| 91 | const writer = { | ||
| 92 | abort: (reason: unknown) => { | ||
| 93 | abortReason = reason; | ||
| 94 | return Promise.resolve(); | ||
| 95 | }, | ||
| 96 | close: () => Promise.resolve(), | ||
| 97 | write: () => { | ||
| 98 | writeCount += 1; | ||
| 99 | stalledWriteStarted?.(); | ||
| 100 | return new Promise<void>(() => {}); | ||
| 101 | }, | ||
| 102 | }; | ||
| 103 | |||
| 104 | vi.stubGlobal( | ||
| 105 | "TransformStream", | ||
| 106 | class { | ||
| 107 | readonly readable = new ReadableStream<Uint8Array>(); | ||
| 108 | readonly writable = { | ||
| 109 | getWriter: () => writer, | ||
| 110 | }; | ||
| 111 | }, | ||
| 112 | ); | ||
| 113 | |||
| 114 | return { | ||
| 115 | abortReason: () => abortReason, | ||
| 116 | stalledWrite, | ||
| 117 | writeCount: () => writeCount, | ||
| 118 | }; | ||
| 119 | }; | ||
| 120 | |||
| 121 | const makeExecutionContext = (): ExecutionContext => ({ | ||
| 122 | passThroughOnException: () => {}, | ||
| 123 | props: undefined, | ||
| 124 | waitUntil: () => {}, | ||
| 125 | }); | ||
| 126 | |||
| 127 | const makeWebSocket = (): FakeWebSocket => { | ||
| 128 | const ws = new EventTarget() as FakeWebSocket; | ||
| 129 | ws.accepted = false; | ||
| 130 | ws.closeCode = undefined; | ||
| 131 | ws.closeReason = undefined; | ||
| 132 | ws.accept = () => { | ||
| 133 | ws.accepted = true; | ||
| 134 | }; | ||
| 135 | ws.close = (code?: number, reason?: string) => { | ||
| 136 | ws.closeCode = code; | ||
| 137 | ws.closeReason = reason; | ||
| 138 | }; | ||
| 139 | return ws; | ||
| 140 | }; | ||
| 141 | |||
| 142 | const makeAgentStub = (ws: FakeWebSocket): FakeAgentStub => ({ | ||
| 143 | setName: async () => {}, | ||
| 144 | getInitializeRequest: async () => ({}), | ||
| 145 | fetch: async () => ({ webSocket: ws }), | ||
| 146 | }); | ||
| 147 | |||
| 148 | const makeNamespace = (agent: FakeAgentStub) => ({ | ||
| 149 | newUniqueId: () => ({ toString: () => "generated-session" }), | ||
| 150 | idFromName: (name: string) => ({ | ||
| 151 | equals: () => true, | ||
| 152 | name, | ||
| 153 | toString: () => name, | ||
| 154 | }), | ||
| 155 | get: () => agent, | ||
| 156 | }); | ||
| 157 | |||
| 158 | const openSse = async () => { | ||
| 159 | const ws = makeWebSocket(); | ||
| 160 | const agent = makeAgentStub(ws); | ||
| 161 | const namespace = makeNamespace(agent); | ||
| 162 | const handler = McpAgent.serve("/mcp", { | ||
| 163 | binding: "MCP_SESSION", | ||
| 164 | transport: "streamable-http", | ||
| 165 | }); | ||
| 166 | const response = await handler.fetch( | ||
| 167 | new Request("https://executor.sh/mcp", { | ||
| 168 | headers: { | ||
| 169 | accept: "text/event-stream", | ||
| 170 | "mcp-session-id": "session-1", | ||
| 171 | }, | ||
| 172 | method: "GET", | ||
| 173 | }), | ||
| 174 | { MCP_SESSION: namespace }, | ||
| 175 | makeExecutionContext(), | ||
| 176 | ); | ||
| 177 | |||
| 178 | expect(response.status).toBe(200); | ||
| 179 | expect(ws.accepted).toBe(true); | ||
| 180 | expect(response.body).toBeDefined(); | ||
| 181 | |||
| 182 | return { response, ws }; | ||
| 183 | }; | ||
| 184 | |||
| 185 | const openPostSse = async () => { | ||
| 186 | const ws = makeWebSocket(); | ||
| 187 | const agent = makeAgentStub(ws); | ||
| 188 | const namespace = makeNamespace(agent); | ||
| 189 | const handler = McpAgent.serve("/mcp", { | ||
| 190 | binding: "MCP_SESSION", | ||
| 191 | transport: "streamable-http", | ||
| 192 | }); | ||
| 193 | const response = await handler.fetch( | ||
| 194 | new Request("https://executor.sh/mcp", { | ||
| 195 | body: JSON.stringify({ | ||
| 196 | id: 1, | ||
| 197 | jsonrpc: "2.0", | ||
| 198 | method: "tools/call", | ||
| 199 | params: { | ||
| 200 | arguments: {}, | ||
| 201 | name: "example", | ||
| 202 | }, | ||
| 203 | }), | ||
| 204 | headers: { | ||
| 205 | accept: "application/json, text/event-stream", | ||
| 206 | "content-type": "application/json", | ||
| 207 | "mcp-session-id": "session-1", | ||
| 208 | }, | ||
| 209 | method: "POST", | ||
| 210 | }), | ||
| 211 | { MCP_SESSION: namespace }, | ||
| 212 | makeExecutionContext(), | ||
| 213 | ); | ||
| 214 | |||
| 215 | expect(response.status).toBe(200); | ||
| 216 | expect(ws.accepted).toBe(true); | ||
| 217 | expect(response.body).toBeDefined(); | ||
| 218 | |||
| 219 | return { response, ws }; | ||
| 220 | }; | ||
| 221 | |||
| 222 | const emitAgentEvent = (ws: FakeWebSocket, event: string, close?: true): void => { | ||
| 223 | ws.dispatchEvent( | ||
| 224 | new MessageEvent("message", { | ||
| 225 | data: JSON.stringify({ | ||
| 226 | close, | ||
| 227 | event, | ||
| 228 | type: "cf_mcp_agent_event", | ||
| 229 | }), | ||
| 230 | }), | ||
| 231 | ); | ||
| 232 | }; | ||
| 233 | |||
| 234 | const emitClose = (ws: FakeWebSocket): void => { | ||
| 235 | ws.dispatchEvent(new Event("close")); | ||
| 236 | }; | ||
| 237 | |||
| 238 | const rotationLogs = (logs: ReadonlyArray<string>): ReadonlyArray<RotationLog> => | ||
| 239 | logs.flatMap((line) => { | ||
| 240 | const decoded = decodeRotationLogEvent(line); | ||
| 241 | return Option.isSome(decoded) ? [decoded.value] : []; | ||
| 242 | }); | ||
| 243 | |||
| 244 | describe("agents SSE max-age rotation", () => { | ||
| 245 | let errorLogs: string[] = []; | ||
| 246 | let infoLogs: string[] = []; | ||
| 247 | |||
| 248 | beforeEach(() => { | ||
| 249 | errorLogs = []; | ||
| 250 | infoLogs = []; | ||
| 251 | vi.useFakeTimers(); | ||
| 252 | vi.setSystemTime(0); | ||
| 253 | vi.spyOn(console, "error").mockImplementation((line) => { | ||
| 254 | errorLogs.push(String(line)); | ||
| 255 | }); | ||
| 256 | vi.spyOn(console, "log").mockImplementation((line) => { | ||
| 257 | infoLogs.push(String(line)); | ||
| 258 | }); | ||
| 259 | }); | ||
| 260 | |||
| 261 | afterEach(() => { | ||
| 262 | vi.unstubAllGlobals(); | ||
| 263 | vi.restoreAllMocks(); | ||
| 264 | vi.useRealTimers(); | ||
| 265 | }); | ||
| 266 | |||
| 267 | it("closes a healthy draining SSE connection within one keepalive tick after max age", async () => { | ||
| 268 | const { response, ws } = await openSse(); | ||
| 269 | const drained = drainResponse(response); | ||
| 270 | |||
| 271 | await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); | ||
| 272 | await waitFor(() => ws.closeCode === 1000); | ||
| 273 | |||
| 274 | expect(ws.closeReason).toBe("sse_max_age_rotation"); | ||
| 275 | const [rotationLog] = rotationLogs(infoLogs); | ||
| 276 | expect(rotationLog?.event).toBe("sse_max_age_close"); | ||
| 277 | expect(rotationLog?.ageMs).toBeGreaterThan(MAX_SSE_AGE_MS); | ||
| 278 | expect(rotationLog?.ageMs).toBeLessThanOrEqual(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); | ||
| 279 | expect(rotationLog?.pendingBytes).toBeGreaterThanOrEqual(0); | ||
| 280 | expect(errorLogs).toEqual([]); | ||
| 281 | expect(vi.getTimerCount()).toBe(0); | ||
| 282 | |||
| 283 | await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n"); | ||
| 284 | }); | ||
| 285 | |||
| 286 | it("does not rotate an in-flight POST response past max age", async () => { | ||
| 287 | const { response, ws } = await openPostSse(); | ||
| 288 | const drained = drainResponse(response); | ||
| 289 | |||
| 290 | emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`); | ||
| 291 | await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS * 4); | ||
| 292 | await flushMicrotasks(); | ||
| 293 | |||
| 294 | expect(ws.closeCode).toBeUndefined(); | ||
| 295 | expect(ws.closeReason).toBeUndefined(); | ||
| 296 | expect(rotationLogs(infoLogs)).toEqual([]); | ||
| 297 | |||
| 298 | emitAgentEvent( | ||
| 299 | ws, | ||
| 300 | `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, | ||
| 301 | true, | ||
| 302 | ); | ||
| 303 | await drained; | ||
| 304 | |||
| 305 | expect(ws.closeCode).toBeUndefined(); | ||
| 306 | expect(ws.closeReason).toBeUndefined(); | ||
| 307 | expect(vi.getTimerCount()).toBe(0); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it("leaves an SSE connection younger than max age untouched", async () => { | ||
| 311 | const { response, ws } = await openSse(); | ||
| 312 | const drained = drainResponse(response); | ||
| 313 | |||
| 314 | await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS - KEEPALIVE_INTERVAL_MS * 2); | ||
| 315 | await flushMicrotasks(); | ||
| 316 | |||
| 317 | expect(ws.closeCode).toBeUndefined(); | ||
| 318 | expect(rotationLogs(infoLogs)).toEqual([]); | ||
| 319 | |||
| 320 | emitClose(ws); | ||
| 321 | await drained; | ||
| 322 | expect(vi.getTimerCount()).toBe(0); | ||
| 323 | }); | ||
| 324 | |||
| 325 | it("still closes a stalled SSE writer at the byte cap without logging rotation", async () => { | ||
| 326 | const stalledFrame = `event: message\ndata: ${"x".repeat(2 * 1024 * 1024)}\n\n`; | ||
| 327 | const transform = installStallingTransformStream(); | ||
| 328 | const { ws } = await openSse(); | ||
| 329 | |||
| 330 | emitAgentEvent(ws, stalledFrame); | ||
| 331 | await transform.stalledWrite; | ||
| 332 | expect(transform.writeCount()).toBe(1); | ||
| 333 | |||
| 334 | const stalledFrameBytes = encoder.encode(stalledFrame).byteLength; | ||
| 335 | expect(stalledFrameBytes).toBeLessThan(MAX_PENDING_SSE_BYTES); | ||
| 336 | |||
| 337 | for (let attempt = 0; attempt < 8 && ws.closeCode === undefined; attempt += 1) { | ||
| 338 | emitAgentEvent(ws, stalledFrame); | ||
| 339 | } | ||
| 340 | |||
| 341 | expect(ws.closeCode).toBe(1013); | ||
| 342 | expect(ws.closeReason).toBe("SSE client not draining"); | ||
| 343 | expect(transform.abortReason()).toBeInstanceOf(Error); | ||
| 344 | expect(rotationLogs(infoLogs)).toEqual([]); | ||
| 345 | expect(vi.getTimerCount()).toBe(0); | ||
| 346 | }); | ||
| 347 | |||
| 348 | it("cleans up timers when the client closes before max age", async () => { | ||
| 349 | const { response, ws } = await openSse(); | ||
| 350 | const drained = drainResponse(response); | ||
| 351 | |||
| 352 | await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS); | ||
| 353 | emitClose(ws); | ||
| 354 | await drained; | ||
| 355 | |||
| 356 | expect(ws.closeCode).toBeUndefined(); | ||
| 357 | expect(rotationLogs(infoLogs)).toEqual([]); | ||
| 358 | expect(vi.getTimerCount()).toBe(0); | ||
| 359 | }); | ||
| 360 | }); | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | import { URLPattern } from "urlpattern-polyfill"; | ||
| 2 | |||
| 3 | type UrlPatternPolyfillGlobal = Omit<typeof globalThis, "URLPattern"> & { | ||
| 4 | URLPattern?: typeof URLPattern; | ||
| 5 | }; | ||
| 6 | |||
| 7 | const globals = globalThis as UrlPatternPolyfillGlobal; | ||
| 8 | |||
| 9 | globals.URLPattern ??= URLPattern; | ||
| @@ -0,0 +1,7 @@ | |||
| 1 | export class EmailMessage { | ||
| 2 | constructor( | ||
| 3 | readonly from: string, | ||
| 4 | readonly to: string, | ||
| 5 | readonly raw: ReadableStream | string, | ||
| 6 | ) {} | ||
| 7 | } | ||
| @@ -0,0 +1,9 @@ | |||
| 1 | export const env: Record<string, unknown> = {}; | ||
| 2 | |||
| 3 | export class DurableObject {} | ||
| 4 | |||
| 5 | export class RpcTarget {} | ||
| 6 | |||
| 7 | export class WorkerEntrypoint {} | ||
| 8 | |||
| 9 | export const exports: Record<string, unknown> = {}; | ||
| @@ -1,8 +1,24 @@ | |||
| 1 | import { dirname, resolve } from "node:path"; | ||
| 2 | import { fileURLToPath } from "node:url"; | ||
| 1 | import { defineConfig } from "vitest/config"; | 3 | import { defineConfig } from "vitest/config"; |
| 2 | 4 | ||
| 5 | const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| 6 | |||
| 3 | export default defineConfig({ | 7 | export default defineConfig({ |
| 8 | resolve: { | ||
| 9 | alias: { | ||
| 10 | "cloudflare:email": resolve(__dirname, "./src/test-stubs/cloudflare-email.ts"), | ||
| 11 | "cloudflare:workers": resolve(__dirname, "./src/test-stubs/cloudflare-workers.ts"), | ||
| 12 | }, | ||
| 13 | }, | ||
| 4 | test: { | 14 | test: { |
| 5 | include: ["src/**/*.test.ts"], | 15 | include: ["src/**/*.test.ts"], |
| 6 | passWithNoTests: true, | 16 | passWithNoTests: true, |
| 17 | setupFiles: ["./src/test-setup.ts"], | ||
| 18 | server: { | ||
| 19 | deps: { | ||
| 20 | inline: ["agents", "partyserver"], | ||
| 21 | }, | ||
| 22 | }, | ||
| 7 | }, | 23 | }, |
| 8 | }); | 24 | }); |
| @@ -37,7 +37,7 @@ The runtime is passed a `SandboxToolInvoker` that bridges sandbox-side tool call · 36 unmodified lines | |||
| 37 | 37 | ||
| 38 | ## Status | 38 | ## Status |
| 39 | 39 | ||
| 40 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 40 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 41 | 41 | ||
| 42 | ## License | 42 | ## License |
| 43 | 43 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/codemode-core", | 2 | "name": "@executor-js/codemode-core", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/kernel/core", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/kernel/core" | 12 | "directory": "packages/kernel/core" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -52,7 +52,7 @@ Use this when you want a different WASM variant (e.g. debug builds, QuickJS-NG) · 51 unmodified lines | |||
| 52 | 52 | ||
| 53 | ## Status | 53 | ## Status |
| 54 | 54 | ||
| 55 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 55 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 56 | 56 | ||
| 57 | ## License | 57 | ## License |
| 58 | 58 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/runtime-quickjs", | 2 | "name": "@executor-js/runtime-quickjs", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/kernel/runtime-quickjs", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/kernel/runtime-quickjs" | 12 | "directory": "packages/kernel/runtime-quickjs" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-desktop-settings", | 2 | "name": "@executor-js/plugin-desktop-settings", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/desktop-settings", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/desktop-settings" | 12 | "directory": "packages/plugins/desktop-settings" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-example", | 2 | "name": "@executor-js/plugin-example", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/example", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/example" | 12 | "directory": "packages/plugins/example" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -52,7 +52,7 @@ Secrets are stored unencrypted in a plain JSON file. Use [`@executor-js/plugin-k · 51 unmodified lines | |||
| 52 | 52 | ||
| 53 | ## Status | 53 | ## Status |
| 54 | 54 | ||
| 55 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 55 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 56 | 56 | ||
| 57 | ## License | 57 | ## License |
| 58 | 58 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-file-secrets", | 2 | "name": "@executor-js/plugin-file-secrets", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/file-secrets", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/file-secrets" | 12 | "directory": "packages/plugins/file-secrets" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-google", | 2 | "name": "@executor-js/plugin-google", |
| 3 | "version": "1.5.26", | 3 | "version": "1.5.26", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/google", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/google", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/google" | 12 | "directory": "packages/plugins/google" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -75,7 +75,7 @@ import { graphqlPlugin } from "@executor-js/plugin-graphql/core"; · 74 unmodified lines | |||
| 75 | 75 | ||
| 76 | ## Status | 76 | ## Status |
| 77 | 77 | ||
| 78 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 78 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 79 | 79 | ||
| 80 | ## License | 80 | ## License |
| 81 | 81 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-graphql", | 2 | "name": "@executor-js/plugin-graphql", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/graphql", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/graphql" | 12 | "directory": "packages/plugins/graphql" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -52,7 +52,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain/core"; · 51 unmodified lines | |||
| 52 | 52 | ||
| 53 | ## Status | 53 | ## Status |
| 54 | 54 | ||
| 55 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 55 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 56 | 56 | ||
| 57 | ## License | 57 | ## License |
| 58 | 58 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-keychain", | 2 | "name": "@executor-js/plugin-keychain", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/keychain", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/keychain" | 12 | "directory": "packages/plugins/keychain" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -60,7 +60,7 @@ import { mcpPlugin } from "@executor-js/plugin-mcp/core"; · 59 unmodified lines | |||
| 60 | 60 | ||
| 61 | ## Status | 61 | ## Status |
| 62 | 62 | ||
| 63 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 63 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 64 | 64 | ||
| 65 | ## License | 65 | ## License |
| 66 | 66 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-mcp", | 2 | "name": "@executor-js/plugin-mcp", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/mcp", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/mcp" | 12 | "directory": "packages/plugins/mcp" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-microsoft", | 2 | "name": "@executor-js/plugin-microsoft", |
| 3 | "version": "1.5.26", | 3 | "version": "1.5.26", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/microsoft", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/microsoft", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/microsoft" | 12 | "directory": "packages/plugins/microsoft" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -72,7 +72,7 @@ import { onepasswordPlugin } from "@executor-js/plugin-onepassword/core"; · 71 unmodified lines | |||
| 72 | 72 | ||
| 73 | ## Status | 73 | ## Status |
| 74 | 74 | ||
| 75 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 75 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 76 | 76 | ||
| 77 | ## License | 77 | ## License |
| 78 | 78 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-onepassword", | 2 | "name": "@executor-js/plugin-onepassword", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/onepassword", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/onepassword" | 12 | "directory": "packages/plugins/onepassword" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -76,7 +76,7 @@ import { openApiPlugin } from "@executor-js/plugin-openapi/core"; · 75 unmodified lines | |||
| 76 | 76 | ||
| 77 | ## Status | 77 | ## Status |
| 78 | 78 | ||
| 79 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor). | 79 | Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor). |
| 80 | 80 | ||
| 81 | ## License | 81 | ## License |
| 82 | 82 | ||
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-openapi", | 2 | "name": "@executor-js/plugin-openapi", |
| 3 | "version": "1.5.27", | 3 | "version": "1.5.27", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/openapi", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/openapi" | 12 | "directory": "packages/plugins/openapi" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-toolkits", | 2 | "name": "@executor-js/plugin-toolkits", |
| 3 | "version": "1.5.19", | 3 | "version": "1.5.19", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/toolkits", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/toolkits" | 12 | "directory": "packages/plugins/toolkits" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@executor-js/plugin-workos-vault", | 2 | "name": "@executor-js/plugin-workos-vault", |
| 3 | "version": "0.0.2", | 3 | "version": "0.0.2", |
| 4 | "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/workos-vault", | 4 | "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/workos-vault", |
| 5 | "bugs": { | 5 | "bugs": { |
| 6 | "url": "https://github.com/RhysSullivan/executor/issues" | 6 | "url": "https://github.com/UsefulSoftwareCo/executor/issues" |
| 7 | }, | 7 | }, |
| 8 | "license": "MIT", | 8 | "license": "MIT", |
| 9 | "repository": { | 9 | "repository": { |
| 10 | "type": "git", | 10 | "type": "git", |
| 11 | "url": "git+https://github.com/RhysSullivan/executor.git", | 11 | "url": "git+https://github.com/UsefulSoftwareCo/executor.git", |
| 12 | "directory": "packages/plugins/workos-vault" | 12 | "directory": "packages/plugins/workos-vault" |
| 13 | }, | 13 | }, |
| 14 | "files": [ | 14 | "files": [ |
| @@ -500,6 +500,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 499 unmodified lines | |||
| 500 | readonly grant: OAuthGrant; | 500 | readonly grant: OAuthGrant; |
| 501 | readonly clientId: string; | 501 | readonly clientId: string; |
| 502 | readonly resource?: string | null; | 502 | readonly resource?: string | null; |
| 503 | readonly originIntegration?: IntegrationSlug | null; | ||
| 503 | }; | 504 | }; |
| 504 | }, | 505 | }, |
| 505 | ) => | 506 | ) => |
| @@ -512,7 +513,9 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 6 unmodified lines | |||
| 512 | tokenUrl: arg.payload.tokenUrl, | 513 | tokenUrl: arg.payload.tokenUrl, |
| 513 | resource: arg.payload.resource ?? null, | 514 | resource: arg.payload.resource ?? null, |
| 514 | clientId: arg.payload.clientId, | 515 | clientId: arg.payload.clientId, |
| 515 | origin: { kind: "manual" }, | 516 | // Mirror the server's stamp so the just-registered app matches its |
| 517 | // integration in the picker immediately (before the refetch lands). | ||
| 518 | origin: { kind: "manual", integration: arg.payload.originIntegration ?? null }, | ||
| 516 | }; | 519 | }; |
| 517 | const index = rows.findIndex( | 520 | const index = rows.findIndex( |
| 518 | (client) => client.owner === summary.owner && client.slug === summary.slug, | 521 | (client) => client.owner === summary.owner && client.slug === summary.slug, |
| @@ -31,6 +31,7 @@ const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): Aut · 30 unmodified lines | |||
| 31 | }); | 31 | }); |
| 32 | 32 | ||
| 33 | type ProbeResult = { | 33 | type ProbeResult = { |
| 34 | readonly issuer?: string | null; | ||
| 34 | readonly authorizationUrl: string; | 35 | readonly authorizationUrl: string; |
| 35 | readonly tokenUrl: string; | 36 | readonly tokenUrl: string; |
| 36 | readonly resource?: string | null; | 37 | readonly resource?: string | null; |
| @@ -42,6 +43,7 @@ type ProbeResult = { · 5 unmodified lines | |||
| 42 | type RegisterArgs = { | 43 | type RegisterArgs = { |
| 43 | readonly owner: Owner; | 44 | readonly owner: Owner; |
| 44 | readonly slug: OAuthClientSlug; | 45 | readonly slug: OAuthClientSlug; |
| 46 | readonly issuer?: string | null; | ||
| 45 | readonly registrationEndpoint: string; | 47 | readonly registrationEndpoint: string; |
| 46 | readonly authorizationUrl: string; | 48 | readonly authorizationUrl: string; |
| 47 | readonly tokenUrl: string; | 49 | readonly tokenUrl: string; |
| @@ -306,6 +308,7 @@ describe("runDcrConnect", () => { · 258 unmodified lines | |||
| 306 | const probe = (_url: string): Promise<ProbeResult | null> => { | 308 | const probe = (_url: string): Promise<ProbeResult | null> => { |
| 307 | calls.push("probe"); | 309 | calls.push("probe"); |
| 308 | return Promise.resolve({ | 310 | return Promise.resolve({ |
| 311 | issuer: "https://auth.example.com", | ||
| 309 | authorizationUrl: "https://auth.example.com/authorize", | 312 | authorizationUrl: "https://auth.example.com/authorize", |
| 310 | tokenUrl: "https://auth.example.com/token", | 313 | tokenUrl: "https://auth.example.com/token", |
| 311 | resource: "https://mcp.example.com/mcp", | 314 | resource: "https://mcp.example.com/mcp", |
| @@ -330,7 +333,6 @@ describe("runDcrConnect", () => { · 18 unmodified lines | |||
| 330 | discoveryUrl: "https://mcp.example.com/mcp", | 333 | discoveryUrl: "https://mcp.example.com/mcp", |
| 331 | owner: "user", | 334 | owner: "user", |
| 332 | integrationName: "Linear MCP", | 335 | integrationName: "Linear MCP", |
| 333 | existingSlugs: [], | ||
| 334 | redirectUri: "https://localhost:5394/api/oauth/callback", | 336 | redirectUri: "https://localhost:5394/api/oauth/callback", |
| 335 | integration: TEST_INTEGRATION, | 337 | integration: TEST_INTEGRATION, |
| 336 | }, | 338 | }, |
| @@ -342,6 +344,8 @@ describe("runDcrConnect", () => { · 5 unmodified lines | |||
| 342 | // DCR always mints an authorization-code/PKCE client; callers do not choose | 344 | // DCR always mints an authorization-code/PKCE client; callers do not choose |
| 343 | // a grant here. | 345 | // a grant here. |
| 344 | expect(registerArgs).not.toBeNull(); | 346 | expect(registerArgs).not.toBeNull(); |
| 347 | expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com"); | ||
| 348 | expect(registerArgs!.issuer).toBe("https://auth.example.com"); | ||
| 345 | expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register"); | 349 | expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register"); |
| 346 | expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize"); | 350 | expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize"); |
| 347 | expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token"); | 351 | expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token"); |
| @@ -379,7 +383,6 @@ describe("runDcrConnect", () => { · 31 unmodified lines | |||
| 379 | resourceFallback: "https://mcp.example.com/mcp", | 383 | resourceFallback: "https://mcp.example.com/mcp", |
| 380 | owner: "user", | 384 | owner: "user", |
| 381 | integrationName: "App", | 385 | integrationName: "App", |
| 382 | existingSlugs: [], | ||
| 383 | declaredScopes: ["declared.scope"], | 386 | declaredScopes: ["declared.scope"], |
| 384 | integration: TEST_INTEGRATION, | 387 | integration: TEST_INTEGRATION, |
| 385 | }, | 388 | }, |
| @@ -416,7 +419,6 @@ describe("runDcrConnect", () => { · 30 unmodified lines | |||
| 416 | discoveryUrl: "https://auth.example.com/token", | 419 | discoveryUrl: "https://auth.example.com/token", |
| 417 | owner: "user", | 420 | owner: "user", |
| 418 | integrationName: "App", | 421 | integrationName: "App", |
| 419 | existingSlugs: [], | ||
| 420 | integration: TEST_INTEGRATION, | 422 | integration: TEST_INTEGRATION, |
| 421 | }, | 423 | }, |
| 422 | ); | 424 | ); |
| @@ -448,7 +450,6 @@ describe("runDcrConnect", () => { · 25 unmodified lines | |||
| 448 | discoveryUrl: "https://mcp.example.com/mcp", | 450 | discoveryUrl: "https://mcp.example.com/mcp", |
| 449 | owner: "user", | 451 | owner: "user", |
| 450 | integrationName: "App", | 452 | integrationName: "App", |
| 451 | existingSlugs: [], | ||
| 452 | integration: TEST_INTEGRATION, | 453 | integration: TEST_INTEGRATION, |
| 453 | }, | 454 | }, |
| 454 | ); | 455 | ); |
| @@ -483,7 +484,6 @@ describe("runDcrConnect", () => { · 28 unmodified lines | |||
| 483 | discoveryUrl: "https://mcp.example.com/mcp", | 484 | discoveryUrl: "https://mcp.example.com/mcp", |
| 484 | owner: "user", | 485 | owner: "user", |
| 485 | integrationName: "App", | 486 | integrationName: "App", |
| 486 | existingSlugs: [], | ||
| 487 | integration: TEST_INTEGRATION, | 487 | integration: TEST_INTEGRATION, |
| 488 | }, | 488 | }, |
| 489 | ); | 489 | ); |
| @@ -513,7 +513,6 @@ describe("runDcrConnect", () => { · 23 unmodified lines | |||
| 513 | discoveryUrl: "https://mcp.example.com/mcp", | 513 | discoveryUrl: "https://mcp.example.com/mcp", |
| 514 | owner: "user", | 514 | owner: "user", |
| 515 | integrationName: "App", | 515 | integrationName: "App", |
| 516 | existingSlugs: [], | ||
| 517 | integration: TEST_INTEGRATION, | 516 | integration: TEST_INTEGRATION, |
| 518 | }, | 517 | }, |
| 519 | ); | 518 | ); |
| @@ -552,7 +551,6 @@ describe("runDcrConnect", () => { · 32 unmodified lines | |||
| 552 | discoveryUrl: "https://mcp.example.com/mcp", | 551 | discoveryUrl: "https://mcp.example.com/mcp", |
| 553 | owner: "user", | 552 | owner: "user", |
| 554 | integrationName: "App", | 553 | integrationName: "App", |
| 555 | existingSlugs: [], | ||
| 556 | integration: TEST_INTEGRATION, | 554 | integration: TEST_INTEGRATION, |
| 557 | }, | 555 | }, |
| 558 | ); | 556 | ); |
| @@ -570,4 +568,37 @@ describe("runDcrConnect", () => { · 11 unmodified lines | |||
| 570 | }); | 568 | }); |
| 571 | expect(started).toBe(false); | 569 | expect(started).toBe(false); |
| 572 | }); | 570 | }); |
| 571 | |||
| 572 | it("mints a deterministic server-keyed slug WITHOUT the picker's slug list", async () => { | ||
| 573 | // Part B removed the DCR connect path's dependency on the picker's app list: | ||
| 574 | // the slug is derived from the authorization server (Part A), so callers no | ||
| 575 | // longer thread `existingSlugs`. This omits it entirely and still succeeds. | ||
| 576 | let registerArgs: RegisterArgs | null = null; | ||
| 577 | const outcome = await runDcrConnect( | ||
| 578 | { | ||
| 579 | probe: (): Promise<ProbeResult | null> => | ||
| 580 | Promise.resolve({ | ||
| 581 | issuer: "https://auth.example.com", | ||
| 582 | authorizationUrl: "https://auth.example.com/authorize", | ||
| 583 | tokenUrl: "https://auth.example.com/token", | ||
| 584 | registrationEndpoint: "https://auth.example.com/register", | ||
| 585 | }), | ||
| 586 | register: (args: RegisterArgs): Promise<OAuthClientSlug | null> => { | ||
| 587 | registerArgs = args; | ||
| 588 | return Promise.resolve(OAuthClientSlug.make("app")); | ||
| 589 | }, | ||
| 590 | start: (): void => {}, | ||
| 591 | }, | ||
| 592 | { | ||
| 593 | discoveryUrl: "https://mcp.example.com/mcp", | ||
| 594 | owner: "user", | ||
| 595 | integrationName: "App", | ||
| 596 | integration: TEST_INTEGRATION, | ||
| 597 | }, | ||
| 598 | ); | ||
| 599 | expect(outcome.kind).toBe("started"); | ||
| 600 | expect(registerArgs).not.toBeNull(); | ||
| 601 | // Slug comes from the issuer host, independent of any picker state. | ||
| 602 | expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com"); | ||
| 603 | }); | ||
| 573 | }); | 604 | }); |
| @@ -62,6 +62,8 @@ import { · 61 unmodified lines | |||
| 62 | import { | 62 | import { |
| 63 | clientDisplayName, | 63 | clientDisplayName, |
| 64 | clientHost, | 64 | clientHost, |
| 65 | optimisticDcrClientSlug, | ||
| 66 | selectDcrClientsForIntegration, | ||
| 65 | uniqueClientSlug, | 67 | uniqueClientSlug, |
| 66 | useOAuthClientsForIntegration, | 68 | useOAuthClientsForIntegration, |
| 67 | type OAuthClientOption, | 69 | type OAuthClientOption, |
| @@ -79,13 +81,14 @@ import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; · 11 unmodified lines | |||
| 79 | import { connectionIdentifier } from "../lib/connection-name"; | 81 | import { connectionIdentifier } from "../lib/connection-name"; |
| 80 | import { Badge } from "./badge"; | 82 | import { Badge } from "./badge"; |
| 81 | import { Button } from "./button"; | 83 | import { Button } from "./button"; |
| 84 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible"; | ||
| 82 | import { | 85 | import { |
| 83 | DropdownMenu, | 86 | DropdownMenu, |
| 84 | DropdownMenuContent, | 87 | DropdownMenuContent, |
| 85 | DropdownMenuItem, | 88 | DropdownMenuItem, |
| 86 | DropdownMenuTrigger, | 89 | DropdownMenuTrigger, |
| 87 | } from "./dropdown-menu"; | 90 | } from "./dropdown-menu"; |
| 88 | import { EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react"; | 91 | import { ChevronDown, EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react"; |
| 89 | import { | 92 | import { |
| 90 | Dialog, | 93 | Dialog, |
| 91 | DialogContent, | 94 | DialogContent, |
| @@ -668,6 +671,7 @@ export async function runCimdConnect( · 576 unmodified lines | |||
| 668 | 671 | ||
| 669 | /** Discovery result from the probe step (subset of the `probeOAuth` response). */ | 672 | /** Discovery result from the probe step (subset of the `probeOAuth` response). */ |
| 670 | type DcrProbeResult = { | 673 | type DcrProbeResult = { |
| 674 | readonly issuer?: string | null; | ||
| 671 | readonly authorizationUrl: string; | 675 | readonly authorizationUrl: string; |
| 672 | readonly tokenUrl: string; | 676 | readonly tokenUrl: string; |
| 673 | readonly resource?: string | null; | 677 | readonly resource?: string | null; |
| @@ -679,6 +683,7 @@ type DcrProbeResult = { · 5 unmodified lines | |||
| 679 | type DcrRegisterArgs = { | 683 | type DcrRegisterArgs = { |
| 680 | readonly owner: Owner; | 684 | readonly owner: Owner; |
| 681 | readonly slug: OAuthClientSlug; | 685 | readonly slug: OAuthClientSlug; |
| 686 | readonly issuer?: string | null; | ||
| 682 | readonly registrationEndpoint: string; | 687 | readonly registrationEndpoint: string; |
| 683 | readonly authorizationUrl: string; | 688 | readonly authorizationUrl: string; |
| 684 | readonly tokenUrl: string; | 689 | readonly tokenUrl: string; |
| @@ -739,8 +744,6 @@ type RunDcrConnectInput = { · 54 unmodified lines | |||
| 739 | readonly resourceFallback?: string; | 744 | readonly resourceFallback?: string; |
| 740 | readonly owner: Owner; | 745 | readonly owner: Owner; |
| 741 | readonly integrationName: string; | 746 | readonly integrationName: string; |
| 742 | /** The owner's existing client slugs, so the minted slug stays unique. */ | ||
| 743 | readonly existingSlugs: readonly string[]; | ||
| 744 | /** Scopes declared by the integration's method (override the probed ones). */ | 747 | /** Scopes declared by the integration's method (override the probed ones). */ |
| 745 | readonly declaredScopes?: readonly string[]; | 748 | readonly declaredScopes?: readonly string[]; |
| 746 | /** Browser-facing callback URL registered with DCR when available. */ | 749 | /** Browser-facing callback URL registered with DCR when available. */ |
| @@ -773,11 +776,12 @@ export async function runDcrConnect( · 26 unmodified lines | |||
| 773 | const registrationEndpoint = probe.registrationEndpoint; | 776 | const registrationEndpoint = probe.registrationEndpoint; |
| 774 | if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe }; | 777 | if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe }; |
| 775 | 778 | ||
| 776 | const slug = uniqueClientSlug(input.integrationName, input.existingSlugs); | 779 | const slug = optimisticDcrClientSlug(probe.issuer ?? registrationEndpoint); |
| 777 | const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []); | 780 | const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []); |
| 778 | const minted = await deps.register({ | 781 | const minted = await deps.register({ |
| 779 | owner: input.owner, | 782 | owner: input.owner, |
| 780 | slug, | 783 | slug, |
| 784 | issuer: probe.issuer ?? null, | ||
| 781 | registrationEndpoint, | 785 | registrationEndpoint, |
| 782 | authorizationUrl: probe.authorizationUrl, | 786 | authorizationUrl: probe.authorizationUrl, |
| 783 | tokenUrl: probe.tokenUrl, | 787 | tokenUrl: probe.tokenUrl, |
| @@ -1160,6 +1164,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 376 unmodified lines | |||
| 1160 | // FIX 3 escape hatch: when no registered app matched the integration's | 1164 | // FIX 3 escape hatch: when no registered app matched the integration's |
| 1161 | // endpoints, the unmatched apps are collapsed behind an opt-in expander. | 1165 | // endpoints, the unmatched apps are collapsed behind an opt-in expander. |
| 1162 | const [showOtherApps, setShowOtherApps] = useState(false); | 1166 | const [showOtherApps, setShowOtherApps] = useState(false); |
| 1167 | // Auto-registered (DCR) clients are plumbing, not pickable apps: they live in a | ||
| 1168 | // collapsed management section at the bottom of the OAuth tab, revealed on | ||
| 1169 | // demand so they can be inspected and deleted without cluttering the picker. | ||
| 1170 | const [showAutoRegistered, setShowAutoRegistered] = useState(false); | ||
| 1163 | // Inline OAuth app management — edit re-opens the registration form for an | 1171 | // Inline OAuth app management — edit re-opens the registration form for an |
| 1164 | // existing app (upsert by owner+slug); remove confirms before deleting. Both | 1172 | // existing app (upsert by owner+slug); remove confirms before deleting. Both |
| 1165 | // hold the FULL app summary (with endpoints + resource) so the edit prefill | 1173 | // hold the FULL app summary (with endpoints + resource) so the edit prefill |
| @@ -1403,6 +1411,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 237 unmodified lines | |||
| 1403 | // unconditionally; in DCR mode the result is ignored until/unless we fall back. | 1411 | // unconditionally; in DCR mode the result is ignored until/unless we fall back. |
| 1404 | const { | 1412 | const { |
| 1405 | clients: oauthApps, | 1413 | clients: oauthApps, |
| 1414 | nearMatches: oauthNearApps, | ||
| 1406 | otherClients: oauthOtherApps, | 1415 | otherClients: oauthOtherApps, |
| 1407 | loading: oauthLoading, | 1416 | loading: oauthLoading, |
| 1408 | endpointMatched: oauthEndpointMatched, | 1417 | endpointMatched: oauthEndpointMatched, |
| @@ -1415,8 +1424,29 @@ function AddAccountModalView(props: AddAccountModalProps) { · 6 unmodified lines | |||
| 1415 | // unrelated provider's app. | 1424 | // unrelated provider's app. |
| 1416 | tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, | 1425 | tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, |
| 1417 | authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, | 1426 | authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, |
| 1427 | // Recorded intent: a manual app registered from THIS integration's dialog is | ||
| 1428 | // a tier-1 match regardless of host. | ||
| 1429 | integration, | ||
| 1418 | requireEndpointMatch: isDcr, | 1430 | requireEndpointMatch: isDcr, |
| 1419 | }); | 1431 | }); |
| 1432 | // Auto-registered (DCR) clients for THIS integration. Excluded from the picker | ||
| 1433 | // above, but surfaced in a collapsed management section so they stay | ||
| 1434 | // deletable. Reads the same optimistic list the picker does. | ||
| 1435 | const dcrClients = useMemo( | ||
| 1436 | () => | ||
| 1437 | selectDcrClientsForIntegration(clientSummaries as readonly OAuthClientOption[], { | ||
| 1438 | integration, | ||
| 1439 | tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, | ||
| 1440 | authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, | ||
| 1441 | }), | ||
| 1442 | [ | ||
| 1443 | clientSummaries, | ||
| 1444 | integration, | ||
| 1445 | method?.oauth?.tokenUrl, | ||
| 1446 | method?.oauth?.authorizationUrl, | ||
| 1447 | oauthFallbackProbe, | ||
| 1448 | ], | ||
| 1449 | ); | ||
| 1420 | const oauthPopup = useOAuthPopupFlow({ | 1450 | const oauthPopup = useOAuthPopupFlow({ |
| 1421 | popupName: "add-account-oauth", | 1451 | popupName: "add-account-oauth", |
| 1422 | detectPopupClosed: false, | 1452 | detectPopupClosed: false, |
| @@ -1433,8 +1463,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines | |||
| 1433 | // Editing reuses the registration form (createClient upserts by owner+slug), | 1463 | // Editing reuses the registration form (createClient upserts by owner+slug), |
| 1434 | // so it occupies the same full-bleed sub-view as registering. | 1464 | // so it occupies the same full-bleed sub-view as registering. |
| 1435 | const oauthEditing = isOAuth && editingClient !== null; | 1465 | const oauthEditing = isOAuth && editingClient !== null; |
| 1466 | // Resolve the picked slug across every PICKABLE tier: exact matches, the | ||
| 1467 | // subdued near-match tier, and the unrelated escape-hatch apps. All three are | ||
| 1468 | // selectable (only the default auto-selection is restricted to tier 1), so the | ||
| 1469 | // connect button must resolve a client from any of them. DCR clients are not | ||
| 1470 | // in these lists, so they can never be chosen as the connect app. | ||
| 1436 | const chosenClient: OAuthClientOption | null = | 1471 | const chosenClient: OAuthClientOption | null = |
| 1437 | oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null; | 1472 | [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find( |
| 1473 | (c: OAuthClientOption) => String(c.slug) === selectedApp, | ||
| 1474 | ) ?? null; | ||
| 1438 | const oauthBusy = ccBusy || oauthPopup.busy; | 1475 | const oauthBusy = ccBusy || oauthPopup.busy; |
| 1439 | const cimdConnecting = cimdBusy || oauthPopup.busy; | 1476 | const cimdConnecting = cimdBusy || oauthPopup.busy; |
| 1440 | const dcrConnecting = dcrBusy || oauthPopup.busy; | 1477 | const dcrConnecting = dcrBusy || oauthPopup.busy; |
| @@ -1898,6 +1935,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 457 unmodified lines | |||
| 1898 | payload: { | 1935 | payload: { |
| 1899 | owner: args.owner, | 1936 | owner: args.owner, |
| 1900 | slug: args.slug, | 1937 | slug: args.slug, |
| 1938 | issuer: args.issuer ?? null, | ||
| 1901 | registrationEndpoint: args.registrationEndpoint, | 1939 | registrationEndpoint: args.registrationEndpoint, |
| 1902 | authorizationUrl: args.authorizationUrl, | 1940 | authorizationUrl: args.authorizationUrl, |
| 1903 | tokenUrl: args.tokenUrl, | 1941 | tokenUrl: args.tokenUrl, |
| @@ -1945,9 +1983,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 41 unmodified lines | |||
| 1945 | resourceFallback: method.oauth?.discoveryUrl, | 1983 | resourceFallback: method.oauth?.discoveryUrl, |
| 1946 | owner: dcrOwner, | 1984 | owner: dcrOwner, |
| 1947 | integrationName, | 1985 | integrationName, |
| 1948 | existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) => | 1986 | // DCR slugs are server-keyed (Part A): the connect path no longer depends |
| 1949 | String(app.slug), | 1987 | // on the picker's app list, so it need not be threaded here. |
| 1950 | ), | ||
| 1951 | declaredScopes: method.oauth?.scopes, | 1988 | declaredScopes: method.oauth?.scopes, |
| 1952 | redirectUri: oauthCallbackUrl(), | 1989 | redirectUri: oauthCallbackUrl(), |
| 1953 | integration, | 1990 | integration, |
| @@ -2014,11 +2051,18 @@ function AddAccountModalView(props: AddAccountModalProps) { · 60 unmodified lines | |||
| 2014 | <div className="px-5 py-5"> | 2051 | <div className="px-5 py-5"> |
| 2015 | <OAuthClientForm | 2052 | <OAuthClientForm |
| 2016 | integrationName={integrationName} | 2053 | integrationName={integrationName} |
| 2017 | existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) => | 2054 | existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))} |
| 2018 | String(app.slug), | ||
| 2019 | )} | ||
| 2020 | fixedSlug={editingClient.slug} | 2055 | fixedSlug={editingClient.slug} |
| 2021 | fixedOwner={editingClient.owner} | 2056 | fixedOwner={editingClient.owner} |
| 2057 | // Preserve the app's already-recorded origin verbatim (only a | ||
| 2058 | // "manual" origin carries a recorded integration; DCR apps | ||
| 2059 | // aren't edited through this form). Editing must never | ||
| 2060 | // re-derive this from the current dialog's integration. | ||
| 2061 | intentIntegration={ | ||
| 2062 | editingClient.origin.kind === "manual" | ||
| 2063 | ? (editingClient.origin.integration ?? null) | ||
| 2064 | : null | ||
| 2065 | } | ||
| 2022 | prefill={{ | 2066 | prefill={{ |
| 2023 | authorizationUrl: editingClient.authorizationUrl, | 2067 | authorizationUrl: editingClient.authorizationUrl, |
| 2024 | tokenUrl: editingClient.tokenUrl, | 2068 | tokenUrl: editingClient.tokenUrl, |
| @@ -2043,9 +2087,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 18 unmodified lines | |||
| 2043 | <div className="px-5 py-5"> | 2087 | <div className="px-5 py-5"> |
| 2044 | <OAuthClientForm | 2088 | <OAuthClientForm |
| 2045 | integrationName={integrationName} | 2089 | integrationName={integrationName} |
| 2046 | existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) => | 2090 | integrationSlug={integration} |
| 2047 | String(app.slug), | 2091 | existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))} |
| 2048 | )} | ||
| 2049 | autoRegisterRejectedReason={dcrFallbackMessage} | 2092 | autoRegisterRejectedReason={dcrFallbackMessage} |
| 2050 | prefill={{ | 2093 | prefill={{ |
| 2051 | authorizationUrl: | 2094 | authorizationUrl: |
| @@ -2063,6 +2106,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 11 unmodified lines | |||
| 2063 | null, | 2106 | null, |
| 2064 | scopes: method.oauth?.scopes, | 2107 | scopes: method.oauth?.scopes, |
| 2065 | discoveredScopes: oauthFallbackProbe?.scopesSupported, | 2108 | discoveredScopes: oauthFallbackProbe?.scopesSupported, |
| 2109 | issuer: oauthFallbackProbe?.issuer ?? null, | ||
| 2066 | registrationEndpoint: | 2110 | registrationEndpoint: |
| 2067 | method.oauth?.registrationEndpoint ?? | 2111 | method.oauth?.registrationEndpoint ?? |
| 2068 | oauthFallbackProbe?.registrationEndpoint ?? | 2112 | oauthFallbackProbe?.registrationEndpoint ?? |
| @@ -2324,6 +2368,107 @@ function AddAccountModalView(props: AddAccountModalProps) { · 255 unmodified lines | |||
| 2324 | </Button> | 2368 | </Button> |
| 2325 | </RadioGroup> | 2369 | </RadioGroup> |
| 2326 | )} | 2370 | )} |
| 2371 | |||
| 2372 | {/* Tier 2 — apps that only match by root domain (a | ||
| 2373 | near-miss, e.g. the same provider's MCP app for a REST | ||
| 2374 | integration). Visually separated and subdued so they are | ||
| 2375 | never mistaken for the integration's own app, but still | ||
| 2376 | selectable as an escape hatch. Shown alongside tier 1, not | ||
| 2377 | mixed into it. */} | ||
| 2378 | {oauthNearApps.length > 0 && ( | ||
| 2379 | <div className="space-y-2 border-t border-border/60 pt-3"> | ||
| 2380 | <p className="text-xs font-medium text-muted-foreground"> | ||
| 2381 | Other apps on this provider | ||
| 2382 | </p> | ||
| 2383 | <p className="text-xs text-muted-foreground"> | ||
| 2384 | These share {integrationName}'s domain but weren't | ||
| 2385 | registered for it. Pick one only if you know it applies. | ||
| 2386 | </p> | ||
| 2387 | <RadioGroup | ||
| 2388 | value={selectedApp} | ||
| 2389 | onValueChange={setPickedApp} | ||
| 2390 | className="gap-2 pt-1" | ||
| 2391 | > | ||
| 2392 | {oauthNearApps.map((app: OAuthClientOption) => ( | ||
| 2393 | <OAuthAppRadioRow | ||
| 2394 | key={String(app.slug)} | ||
| 2395 | app={app} | ||
| 2396 | idPrefix="near-app" | ||
| 2397 | variant="other" | ||
| 2398 | showOwnerLabel={ownerDisplay.showOwnerLabels} | ||
| 2399 | onManage={manageHandlersFor(app)} | ||
| 2400 | /> | ||
| 2401 | ))} | ||
| 2402 | </RadioGroup> | ||
| 2403 | </div> | ||
| 2404 | )} | ||
| 2405 | |||
| 2406 | {/* Auto-registered (DCR) clients: plumbing, never | ||
| 2407 | pickable. Kept here, collapsed, so they can be reviewed | ||
| 2408 | and deleted without cluttering the picker. */} | ||
| 2409 | {dcrClients.length > 0 && ( | ||
| 2410 | <div className="border-t border-border/60 pt-3"> | ||
| 2411 | <Collapsible | ||
| 2412 | open={showAutoRegistered} | ||
| 2413 | onOpenChange={setShowAutoRegistered} | ||
| 2414 | > | ||
| 2415 | <CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"> | ||
| 2416 | <span>Auto-registered clients ({dcrClients.length})</span> | ||
| 2417 | <ChevronDown | ||
| 2418 | className={cn( | ||
| 2419 | "size-3.5 transition-transform", | ||
| 2420 | showAutoRegistered && "rotate-180", | ||
| 2421 | )} | ||
| 2422 | aria-hidden="true" | ||
| 2423 | /> | ||
| 2424 | </CollapsibleTrigger> | ||
| 2425 | <CollapsibleContent> | ||
| 2426 | <div className="space-y-2 pt-2"> | ||
| 2427 | <p className="text-xs text-muted-foreground"> | ||
| 2428 | Created automatically when connecting. Reused across | ||
| 2429 | connections, not selectable as your app. | ||
| 2430 | </p> | ||
| 2431 | {dcrClients.map((app: OAuthClientOption) => ( | ||
| 2432 | <div | ||
| 2433 | key={String(app.slug)} | ||
| 2434 | className="flex items-center gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5" | ||
| 2435 | > | ||
| 2436 | <span className="min-w-0 flex-1"> | ||
| 2437 | <span className="block text-sm font-medium"> | ||
| 2438 | {clientDisplayName(String(app.slug))} | ||
| 2439 | </span> | ||
| 2440 | <span className="block truncate text-xs text-muted-foreground"> | ||
| 2441 | {clientHost(app.tokenUrl)} | ||
| 2442 | </span> | ||
| 2443 | </span> | ||
| 2444 | {ownerDisplay.showOwnerLabels ? ( | ||
| 2445 | <Badge variant="outline"> | ||
| 2446 | {ownerLabel(app.owner)} | ||
| 2447 | </Badge> | ||
| 2448 | ) : null} | ||
| 2449 | <Button | ||
| 2450 | type="button" | ||
| 2451 | variant="ghost" | ||
| 2452 | size="sm" | ||
| 2453 | className="shrink-0 text-destructive hover:text-destructive" | ||
| 2454 | onClick={() => { | ||
| 2455 | const summary = clientSummaries.find( | ||
| 2456 | (c: OAuthClientSummary) => | ||
| 2457 | c.owner === app.owner && | ||
| 2458 | String(c.slug) === String(app.slug), | ||
| 2459 | ); | ||
| 2460 | if (summary) setRemovingClient(summary); | ||
| 2461 | }} | ||
| 2462 | > | ||
| 2463 | Remove | ||
| 2464 | </Button> | ||
| 2465 | </div> | ||
| 2466 | ))} | ||
| 2467 | </div> | ||
| 2468 | </CollapsibleContent> | ||
| 2469 | </Collapsible> | ||
| 2470 | </div> | ||
| 2471 | )} | ||
| 2327 | </div> | 2472 | </div> |
| 2328 | ) | 2473 | ) |
| 2329 | ) : ( | 2474 | ) : ( |
| @@ -1,6 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 2 | 3 | ||
| 3 | import { canSubmitOAuthClientForm, registrationScopes } from "./oauth-client-form"; | 4 | import { |
| 5 | canSubmitOAuthClientForm, | ||
| 6 | registrationScopes, | ||
| 7 | resolveOriginIntegration, | ||
| 8 | } from "./oauth-client-form"; | ||
| 4 | 9 | ||
| 5 | const validBase = { | 10 | const validBase = { |
| 6 | submitting: false, | 11 | submitting: false, |
| @@ -36,6 +41,29 @@ describe("registrationScopes", () => { · 29 unmodified lines | |||
| 36 | }); | 41 | }); |
| 37 | }); | 42 | }); |
| 38 | 43 | ||
| 44 | describe("resolveOriginIntegration", () => { | ||
| 45 | const INTEG = IntegrationSlug.make("linear_mcp"); | ||
| 46 | const OTHER = IntegrationSlug.make("github"); | ||
| 47 | |||
| 48 | it("stamps the current integration for a fresh registration (no explicit intent)", () => { | ||
| 49 | expect(resolveOriginIntegration(undefined, INTEG)).toBe(INTEG); | ||
| 50 | }); | ||
| 51 | |||
| 52 | it("stamps null for a fresh registration outside any integration context", () => { | ||
| 53 | expect(resolveOriginIntegration(undefined, undefined)).toBeNull(); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("preserves an explicit null intent when editing — does NOT re-stamp the current integration", () => { | ||
| 57 | // Regression: editing used to send `fixedSlug ? null : integrationSlug`, | ||
| 58 | // which clobbered an app's recorded origin with null on every edit. | ||
| 59 | expect(resolveOriginIntegration(null, INTEG)).toBeNull(); | ||
| 60 | }); | ||
| 61 | |||
| 62 | it("preserves an explicit non-null intent when editing, even if it differs from the current integration", () => { | ||
| 63 | expect(resolveOriginIntegration(OTHER, INTEG)).toBe(OTHER); | ||
| 64 | }); | ||
| 65 | }); | ||
| 66 | |||
| 39 | describe("canSubmitOAuthClientForm", () => { | 67 | describe("canSubmitOAuthClientForm", () => { |
| 40 | it("allows authorization-code public clients with no client secret", () => { | 68 | it("allows authorization-code public clients with no client secret", () => { |
| 41 | expect( | 69 | expect( |
| @@ -1,7 +1,12 @@ | |||
| 1 | import { useMemo, useState } from "react"; | 1 | import { useMemo, useState } from "react"; |
| 2 | import { useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared"; | 4 | import { |
| 5 | OAuthClientSlug, | ||
| 6 | type IntegrationSlug, | ||
| 7 | type OAuthGrant, | ||
| 8 | type Owner, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 5 | import { toast } from "sonner"; | 10 | import { toast } from "sonner"; |
| 6 | 11 | ||
| 7 | import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms"; | 12 | import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms"; |
| @@ -9,7 +14,7 @@ import { ownerLabelForHost } from "../api/owner-display"; · 1 unmodified line | |||
| 9 | import { trackEvent } from "../api/analytics"; | 14 | import { trackEvent } from "../api/analytics"; |
| 10 | import { useOrganizationId } from "../api/organization-context"; | 15 | import { useOrganizationId } from "../api/organization-context"; |
| 11 | import { oauthClientWriteKeys } from "../api/reactivity-keys"; | 16 | import { oauthClientWriteKeys } from "../api/reactivity-keys"; |
| 12 | import { uniqueClientSlug } from "../plugins/use-effective-oauth-client"; | 17 | import { optimisticDcrClientSlug, uniqueClientSlug } from "../plugins/use-effective-oauth-client"; |
| 13 | import { oauthCallbackUrl } from "../plugins/oauth-sign-in"; | 18 | import { oauthCallbackUrl } from "../plugins/oauth-sign-in"; |
| 14 | import { | 19 | import { |
| 15 | ConnectionOwnerDropdown, | 20 | ConnectionOwnerDropdown, |
| @@ -36,6 +41,7 @@ import { RadioGroup, RadioGroupItem } from "./radio-group"; · 20 unmodified lines | |||
| 36 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
| 37 | 42 | ||
| 38 | export interface OAuthClientFormPrefill { | 43 | export interface OAuthClientFormPrefill { |
| 44 | readonly issuer?: string | null; | ||
| 39 | readonly authorizationUrl?: string; | 45 | readonly authorizationUrl?: string; |
| 40 | readonly tokenUrl?: string; | 46 | readonly tokenUrl?: string; |
| 41 | readonly resource?: string | null; | 47 | readonly resource?: string | null; |
| @@ -70,6 +76,19 @@ export const registrationScopes = ( · 28 unmodified lines | |||
| 70 | discoveredScopes: readonly string[], | 76 | discoveredScopes: readonly string[], |
| 71 | ): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes); | 77 | ): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes); |
| 72 | 78 | ||
| 79 | /** The `originIntegration` to send with a `createClient` payload. When the | ||
| 80 | * caller passes an explicit `intentIntegration` (edit flow: the app's | ||
| 81 | * preserved recorded intent, which may legitimately be `null`), it wins | ||
| 82 | * verbatim — editing must never silently overwrite a stamp. Otherwise this is | ||
| 83 | * a fresh registration from an integration's dialog, which stamps the current | ||
| 84 | * integration as recorded intent (or `null` outside any integration | ||
| 85 | * context). */ | ||
| 86 | export const resolveOriginIntegration = ( | ||
| 87 | intentIntegration: IntegrationSlug | null | undefined, | ||
| 88 | integrationSlug: IntegrationSlug | undefined, | ||
| 89 | ): IntegrationSlug | null => | ||
| 90 | intentIntegration !== undefined ? intentIntegration : (integrationSlug ?? null); | ||
| 91 | |||
| 73 | export const canSubmitOAuthClientForm = (input: { | 92 | export const canSubmitOAuthClientForm = (input: { |
| 74 | readonly submitting: boolean; | 93 | readonly submitting: boolean; |
| 75 | readonly name: string; | 94 | readonly name: string; |
| @@ -89,6 +108,19 @@ export const canSubmitOAuthClientForm = (input: { · 13 unmodified lines | |||
| 89 | export function OAuthClientForm(props: { | 108 | export function OAuthClientForm(props: { |
| 90 | /** Human label for the integration this app backs (used in toasts + default name). */ | 109 | /** Human label for the integration this app backs (used in toasts + default name). */ |
| 91 | readonly integrationName: string; | 110 | readonly integrationName: string; |
| 111 | /** Slug of the integration whose dialog this form is registering from, when | ||
| 112 | * known. Stamped onto the created MANUAL app as recorded intent so the picker | ||
| 113 | * matches it to this integration exactly (not by root-domain guess). Omitted | ||
| 114 | * when editing (an existing app's origin is fixed) or when there is no single | ||
| 115 | * integration context. */ | ||
| 116 | readonly integrationSlug?: IntegrationSlug; | ||
| 117 | /** Explicit recorded-intent stamp to send verbatim, overriding the default | ||
| 118 | * derivation from `integrationSlug`. Set this when editing an existing app | ||
| 119 | * so its ALREADY-recorded origin (which may be `null`) is preserved rather | ||
| 120 | * than re-derived from the current dialog's integration context. Omit for a | ||
| 121 | * fresh registration, where the default (stamp `integrationSlug`, or `null` | ||
| 122 | * outside an integration context) is correct. */ | ||
| 123 | readonly intentIntegration?: IntegrationSlug | null; | ||
| 92 | /** Existing client slugs, so the generated slug stays unique across apps. */ | 124 | /** Existing client slugs, so the generated slug stays unique across apps. */ |
| 93 | readonly existingSlugs: readonly string[]; | 125 | readonly existingSlugs: readonly string[]; |
| 94 | /** Endpoints/scopes declared by the integration's OAuth method. */ | 126 | /** Endpoints/scopes declared by the integration's OAuth method. */ |
| @@ -112,6 +144,8 @@ export function OAuthClientForm(props: { · 17 unmodified lines | |||
| 112 | }) { | 144 | }) { |
| 113 | const { | 145 | const { |
| 114 | integrationName, | 146 | integrationName, |
| 147 | integrationSlug, | ||
| 148 | intentIntegration, | ||
| 115 | existingSlugs, | 149 | existingSlugs, |
| 116 | prefill, | 150 | prefill, |
| 117 | fixedSlug, | 151 | fixedSlug, |
| @@ -147,6 +181,7 @@ export function OAuthClientForm(props: { · 29 unmodified lines | |||
| 147 | const [clientId, setClientId] = useState(prefill?.clientId ?? ""); | 181 | const [clientId, setClientId] = useState(prefill?.clientId ?? ""); |
| 148 | const [clientSecret, setClientSecret] = useState(""); | 182 | const [clientSecret, setClientSecret] = useState(""); |
| 149 | const [issuerUrl, setIssuerUrl] = useState(""); | 183 | const [issuerUrl, setIssuerUrl] = useState(""); |
| 184 | const [discoveredIssuer, setDiscoveredIssuer] = useState<string | null>(prefill?.issuer ?? null); | ||
| 150 | const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? ""); | 185 | const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? ""); |
| 151 | const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? ""); | 186 | const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? ""); |
| 152 | const [resource, setResource] = useState(prefill?.resource ?? null); | 187 | const [resource, setResource] = useState(prefill?.resource ?? null); |
| @@ -226,6 +261,7 @@ export function OAuthClientForm(props: { · 73 unmodified lines | |||
| 226 | return; | 261 | return; |
| 227 | } | 262 | } |
| 228 | const result = exit.value; | 263 | const result = exit.value; |
| 264 | setDiscoveredIssuer(result.issuer ?? null); | ||
| 229 | setAuthorizationUrl(result.authorizationUrl); | 265 | setAuthorizationUrl(result.authorizationUrl); |
| 230 | setTokenUrl(result.tokenUrl); | 266 | setTokenUrl(result.tokenUrl); |
| 231 | setResource(result.resource ?? null); | 267 | setResource(result.resource ?? null); |
| @@ -247,11 +283,13 @@ export function OAuthClientForm(props: { · 15 unmodified lines | |||
| 247 | const handleRegisterDynamic = async () => { | 283 | const handleRegisterDynamic = async () => { |
| 248 | if (!canRegisterDynamic || registering) return; | 284 | if (!canRegisterDynamic || registering) return; |
| 249 | setRegistering(true); | 285 | setRegistering(true); |
| 250 | const slug = fixedSlug ?? uniqueClientSlug(name, existingSlugs); | 286 | const slug = |
| 287 | fixedSlug ?? optimisticDcrClientSlug(discoveredIssuer ?? registrationEndpoint.trim()); | ||
| 251 | const exit = await doRegisterDynamic({ | 288 | const exit = await doRegisterDynamic({ |
| 252 | payload: { | 289 | payload: { |
| 253 | owner, | 290 | owner, |
| 254 | slug, | 291 | slug, |
| 292 | issuer: discoveredIssuer, | ||
| 255 | registrationEndpoint: registrationEndpoint.trim(), | 293 | registrationEndpoint: registrationEndpoint.trim(), |
| 256 | authorizationUrl: authorizationUrl.trim(), | 294 | authorizationUrl: authorizationUrl.trim(), |
| 257 | tokenUrl: tokenUrl.trim(), | 295 | tokenUrl: tokenUrl.trim(), |
| @@ -273,7 +311,7 @@ export function OAuthClientForm(props: { · 15 unmodified lines | |||
| 273 | } | 311 | } |
| 274 | trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true }); | 312 | trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true }); |
| 275 | toast.success(`Registered ${integrationName} OAuth app`); | 313 | toast.success(`Registered ${integrationName} OAuth app`); |
| 276 | onCreated({ owner, slug }); | 314 | onCreated({ owner, slug: exit.value.client }); |
| 277 | }; | 315 | }; |
| 278 | 316 | ||
| 279 | const handleSubmit = async () => { | 317 | const handleSubmit = async () => { |
| @@ -290,6 +328,10 @@ export function OAuthClientForm(props: { · 10 unmodified lines | |||
| 290 | clientId: clientId.trim(), | 328 | clientId: clientId.trim(), |
| 291 | clientSecret: clientSecret.trim(), | 329 | clientSecret: clientSecret.trim(), |
| 292 | resource, | 330 | resource, |
| 331 | // Editing preserves the app's already-recorded origin (via | ||
| 332 | // `intentIntegration`, passed verbatim by the caller); a fresh | ||
| 333 | // registration from an integration's dialog stamps recorded intent. | ||
| 334 | originIntegration: resolveOriginIntegration(intentIntegration, integrationSlug), | ||
| 293 | }, | 335 | }, |
| 294 | reactivityKeys: oauthClientWriteKeys, | 336 | reactivityKeys: oauthClientWriteKeys, |
| 295 | }); | 337 | }); |
| @@ -0,0 +1,125 @@ | |||
| 1 | // The DCR root-domain collision at the picker-classification level (issue #1120). | ||
| 2 | // | ||
| 3 | // Two DIFFERENT integrations share one registrable root domain (cloudflare.com) | ||
| 4 | // but differ by host: a "Cloudflare" MCP integration whose OAuth is on | ||
| 5 | // mcp.cloudflare.com, and the plain "Cloudflare" REST integration on | ||
| 6 | // api.cloudflare.com. Connecting the MCP integration Dynamic-Client-Registers an | ||
| 7 | // oauth_client (origin: dynamic_client_registration) against mcp.cloudflare.com. | ||
| 8 | // | ||
| 9 | // The regression PR #1139 missed: that auto-registered client leaking into the | ||
| 10 | // REST integration's Add-connection app picker. The existing unit tests in | ||
| 11 | // use-effective-oauth-client.test.ts check DCR exclusion and root-domain tiering | ||
| 12 | // one picker at a time; this file asserts the CROSS-INTEGRATION property those | ||
| 13 | // don't: the MCP integration's DCR client is offered in NONE of the REST | ||
| 14 | // integration's picker tiers, yet stays visible in the MCP integration's own | ||
| 15 | // auto-registered management list. It runs the exact pure classifier the app | ||
| 16 | // (add-account-modal.tsx via useOAuthClientsForIntegration) uses to build the | ||
| 17 | // picker, so it guards the shipped behaviour, not a reimplementation. | ||
| 18 | // | ||
| 19 | // The server-side half (that DCR actually mints this row over the wire and | ||
| 20 | // reuses it on reconnect) is covered by e2e/cloud/dcr-root-domain-isolation.test.ts. | ||
| 21 | import { describe, expect, it } from "@effect/vitest"; | ||
| 22 | import { IntegrationSlug, OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; | ||
| 23 | import type { OAuthClientOrigin } from "@executor-js/sdk/shared"; | ||
| 24 | |||
| 25 | import { | ||
| 26 | selectClientsForEndpoints, | ||
| 27 | selectDcrClientsForIntegration, | ||
| 28 | type OAuthClientOption, | ||
| 29 | } from "./use-effective-oauth-client"; | ||
| 30 | |||
| 31 | const client = ( | ||
| 32 | slug: string, | ||
| 33 | opts: { | ||
| 34 | readonly owner?: Owner; | ||
| 35 | readonly authorizationUrl: string; | ||
| 36 | readonly tokenUrl: string; | ||
| 37 | readonly origin: OAuthClientOrigin; | ||
| 38 | }, | ||
| 39 | ): OAuthClientOption => ({ | ||
| 40 | owner: opts.owner ?? "org", | ||
| 41 | slug: OAuthClientSlug.make(slug), | ||
| 42 | grant: "authorization_code", | ||
| 43 | authorizationUrl: opts.authorizationUrl, | ||
| 44 | tokenUrl: opts.tokenUrl, | ||
| 45 | clientId: "client-id", | ||
| 46 | origin: opts.origin, | ||
| 47 | }); | ||
| 48 | |||
| 49 | const MCP_INTEGRATION = IntegrationSlug.make("cloudflare_mcp"); | ||
| 50 | const REST_INTEGRATION = IntegrationSlug.make("cloudflare_api"); | ||
| 51 | |||
| 52 | // The auto-registered client the MCP connect flow mints, exactly as the API | ||
| 53 | // projects it (origin.kind = dynamic_client_registration, endpoints on the MCP | ||
| 54 | // host, stamped with the MCP integration as its origin). | ||
| 55 | const mcpDcrClient = client("dcr-mcp-cloudflare-com", { | ||
| 56 | authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| 57 | tokenUrl: "https://mcp.cloudflare.com/token", | ||
| 58 | origin: { kind: "dynamic_client_registration", integration: MCP_INTEGRATION }, | ||
| 59 | }); | ||
| 60 | |||
| 61 | // What the REST integration (api.cloudflare.com) declares to its picker. | ||
| 62 | const restEndpoints = { | ||
| 63 | tokenUrl: "https://api.cloudflare.com/client/v4/oauth/token", | ||
| 64 | authorizationUrl: "https://api.cloudflare.com/client/v4/oauth/authorize", | ||
| 65 | integration: REST_INTEGRATION, | ||
| 66 | requireEndpointMatch: true, | ||
| 67 | } as const; | ||
| 68 | |||
| 69 | describe("DCR root-domain isolation across two same-provider integrations", () => { | ||
| 70 | it("the MCP integration's DCR client is offered in NO tier of the REST integration's picker", () => { | ||
| 71 | const rest = selectClientsForEndpoints([mcpDcrClient], restEndpoints); | ||
| 72 | |||
| 73 | const offeredInAnyTier = [...rest.matched, ...rest.nearMatches, ...rest.unmatched].map((c) => | ||
| 74 | String(c.slug), | ||
| 75 | ); | ||
| 76 | expect( | ||
| 77 | offeredInAnyTier, | ||
| 78 | "the auto-registered client never surfaces as a selectable app for a same-root integration", | ||
| 79 | ).not.toContain("dcr-mcp-cloudflare-com"); | ||
| 80 | // The three tiers ARE the whole picker; nothing selectable remains. | ||
| 81 | expect(offeredInAnyTier, "the picker offers nothing to select").toEqual([]); | ||
| 82 | expect( | ||
| 83 | rest.matched.length, | ||
| 84 | "no exact/intent match, so the REST picker shows the register-an-app CTA", | ||
| 85 | ).toBe(0); | ||
| 86 | expect(rest.endpointMatched, "an endpoint was declared but nothing matched exactly").toBe( | ||
| 87 | false, | ||
| 88 | ); | ||
| 89 | }); | ||
| 90 | |||
| 91 | it("a same-root MANUAL app (not DCR) still lands in the near-matches tier, proving the exclusion is DCR-specific", () => { | ||
| 92 | // Same mcp.cloudflare.com endpoints, but a manual app: the classifier must | ||
| 93 | // still surface it (as a subdued near-match), so the empty picker above is | ||
| 94 | // due to the DCR origin, not the host mismatch. | ||
| 95 | const manualNearMiss = client("cloudflare-mcp-manual", { | ||
| 96 | authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| 97 | tokenUrl: "https://mcp.cloudflare.com/token", | ||
| 98 | origin: { kind: "manual", integration: null }, | ||
| 99 | }); | ||
| 100 | const rest = selectClientsForEndpoints([manualNearMiss, mcpDcrClient], restEndpoints); | ||
| 101 | |||
| 102 | expect( | ||
| 103 | rest.nearMatches.map((c) => String(c.slug)), | ||
| 104 | "the manual same-root app is a tier-2 near-match", | ||
| 105 | ).toEqual(["cloudflare-mcp-manual"]); | ||
| 106 | expect( | ||
| 107 | [...rest.matched, ...rest.nearMatches, ...rest.unmatched].map((c) => String(c.slug)), | ||
| 108 | "the DCR client is still excluded even alongside a pickable manual app", | ||
| 109 | ).not.toContain("dcr-mcp-cloudflare-com"); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it("the DCR client stays visible in the MCP integration's own auto-registered management list", () => { | ||
| 113 | // Hidden from the picker is not hidden from management: the MCP integration | ||
| 114 | // that minted it must still be able to review and remove it. | ||
| 115 | const managed = selectDcrClientsForIntegration([mcpDcrClient], { | ||
| 116 | integration: MCP_INTEGRATION, | ||
| 117 | tokenUrl: "https://mcp.cloudflare.com/token", | ||
| 118 | authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| 119 | }); | ||
| 120 | expect( | ||
| 121 | managed.map((c) => String(c.slug)), | ||
| 122 | "the auto-registered client is manageable from the integration that created it", | ||
| 123 | ).toEqual(["dcr-mcp-cloudflare-com"]); | ||
| 124 | }); | ||
| 125 | }); | ||
| @@ -1,8 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; | 2 | import { IntegrationSlug, OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; |
| 3 | import type { OAuthClientOrigin } from "@executor-js/sdk/shared"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 6 | optimisticDcrClientSlug, | ||
| 5 | selectClientsForEndpoints, | 7 | selectClientsForEndpoints, |
| 8 | selectDcrClientsForIntegration, | ||
| 6 | uniqueClientSlug, | 9 | uniqueClientSlug, |
| 7 | type OAuthClientOption, | 10 | type OAuthClientOption, |
| 8 | } from "./use-effective-oauth-client"; | 11 | } from "./use-effective-oauth-client"; |
| @@ -13,6 +16,7 @@ const app = ( · 4 unmodified lines | |||
| 13 | readonly owner?: Owner; | 16 | readonly owner?: Owner; |
| 14 | readonly authorizationUrl: string; | 17 | readonly authorizationUrl: string; |
| 15 | readonly tokenUrl: string; | 18 | readonly tokenUrl: string; |
| 19 | readonly origin?: OAuthClientOrigin; | ||
| 16 | }, | 20 | }, |
| 17 | ): OAuthClientOption => ({ | 21 | ): OAuthClientOption => ({ |
| 18 | owner: opts.owner ?? "user", | 22 | owner: opts.owner ?? "user", |
| @@ -21,6 +25,7 @@ const app = ( · 2 unmodified lines | |||
| 21 | authorizationUrl: opts.authorizationUrl, | 25 | authorizationUrl: opts.authorizationUrl, |
| 22 | tokenUrl: opts.tokenUrl, | 26 | tokenUrl: opts.tokenUrl, |
| 23 | clientId: "client-id", | 27 | clientId: "client-id", |
| 28 | origin: opts.origin ?? { kind: "manual" }, | ||
| 24 | }); | 29 | }); |
| 25 | 30 | ||
| 26 | const google = app("google-app", { | 31 | const google = app("google-app", { |
| @@ -89,6 +94,27 @@ describe("selectClientsForEndpoints", () => { · 62 unmodified lines | |||
| 89 | ]); | 94 | ]); |
| 90 | }); | 95 | }); |
| 91 | 96 | ||
| 97 | it("does not tier-1 match an unparseable app URL against a scheme-less declared endpoint", () => { | ||
| 98 | // Regression: `hostOf` returns undefined for URLs `new URL()` can't parse. | ||
| 99 | // An integration whose manifest declares a scheme-less authorization URL | ||
| 100 | // ("oauth.cloudflare.com", no validation on manifest Schema.String) and an | ||
| 101 | // unrelated app whose stored URL is also unparseable would both yield | ||
| 102 | // `undefined` hosts, so a naive `appAuthorizationHost === wantedAuthorizationHost` | ||
| 103 | // is `undefined === undefined` → a false tier-1 (default-picked) match. The | ||
| 104 | // guard must require BOTH hosts defined, so this app lands in `unmatched`. | ||
| 105 | const unparseable = app("mystery-app", { | ||
| 106 | authorizationUrl: "not a url", | ||
| 107 | tokenUrl: "not a url", | ||
| 108 | }); | ||
| 109 | const result = selectClientsForEndpoints([unparseable], { | ||
| 110 | authorizationUrl: "oauth.cloudflare.com", | ||
| 111 | }); | ||
| 112 | expect(result.matched).toEqual([]); | ||
| 113 | expect(result.nearMatches).toEqual([]); | ||
| 114 | expect(result.endpointMatched).toBe(false); | ||
| 115 | expect(result.unmatched.map((a: OAuthClientOption) => String(a.slug))).toEqual(["mystery-app"]); | ||
| 116 | }); | ||
| 117 | |||
| 92 | it("treats every app as usable when no endpoint is declared", () => { | 118 | it("treats every app as usable when no endpoint is declared", () => { |
| 93 | const result = selectClientsForEndpoints([google, spotify], {}); | 119 | const result = selectClientsForEndpoints([google, spotify], {}); |
| 94 | expect(result.endpointMatched).toBe(true); | 120 | expect(result.endpointMatched).toBe(true); |
| @@ -133,6 +159,161 @@ describe("selectClientsForEndpoints", () => { · 38 unmodified lines | |||
| 133 | }); | 159 | }); |
| 134 | expect(result.matched.map((a: OAuthClientOption) => a.owner)).toEqual(["user", "org"]); | 160 | expect(result.matched.map((a: OAuthClientOption) => a.owner)).toEqual(["user", "org"]); |
| 135 | }); | 161 | }); |
| 162 | |||
| 163 | it("excludes DCR clients from the picker entirely (they are plumbing, not apps)", () => { | ||
| 164 | // A DCR client minted against the integration's exact endpoint STILL must not | ||
| 165 | // appear in the picker — it is reused automatically, never user-picked. | ||
| 166 | const dcr = app("dcr-oauth2-googleapis-com", { | ||
| 167 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 168 | tokenUrl: "https://oauth2.googleapis.com/token", | ||
| 169 | origin: { kind: "dynamic_client_registration", integration: null }, | ||
| 170 | }); | ||
| 171 | const result = selectClientsForEndpoints([dcr, google], { | ||
| 172 | tokenUrl: "https://oauth2.googleapis.com/token", | ||
| 173 | }); | ||
| 174 | // Only the manual google-app survives into any tier; the DCR client is gone. | ||
| 175 | expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual(["google-app"]); | ||
| 176 | expect(result.nearMatches).toEqual([]); | ||
| 177 | expect(result.unmatched).toEqual([]); | ||
| 178 | }); | ||
| 179 | |||
| 180 | it("origin-integration (recorded intent) is a tier-1 match, beating a root-domain near-miss", () => { | ||
| 181 | const integration = IntegrationSlug.make("cloudflare_api"); | ||
| 182 | // Registered from the Cloudflare API dialog: exact intent, even though its | ||
| 183 | // OAuth host (dash.cloudflare.com) is not the declared api.cloudflare.com. | ||
| 184 | const stamped = app("my-cloudflare-app", { | ||
| 185 | authorizationUrl: "https://dash.cloudflare.com/oauth2/auth", | ||
| 186 | tokenUrl: "https://dash.cloudflare.com/oauth2/token", | ||
| 187 | origin: { kind: "manual", integration }, | ||
| 188 | }); | ||
| 189 | // Same root domain but different host and no intent stamp: a near-miss. | ||
| 190 | const nearMiss = app("cloudflare-mcp-app", { | ||
| 191 | authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| 192 | tokenUrl: "https://mcp.cloudflare.com/token", | ||
| 193 | }); | ||
| 194 | const result = selectClientsForEndpoints([nearMiss, stamped], { | ||
| 195 | tokenUrl: "https://api.cloudflare.com/client/v4/token", | ||
| 196 | authorizationUrl: "https://api.cloudflare.com/client/v4/authorize", | ||
| 197 | integration, | ||
| 198 | }); | ||
| 199 | expect(result.endpointMatched).toBe(true); | ||
| 200 | // Intent wins tier 1; the root-domain near-miss lands in tier 2, never mixed in. | ||
| 201 | expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ | ||
| 202 | "my-cloudflare-app", | ||
| 203 | ]); | ||
| 204 | expect(result.nearMatches.map((a: OAuthClientOption) => String(a.slug))).toEqual([ | ||
| 205 | "cloudflare-mcp-app", | ||
| 206 | ]); | ||
| 207 | expect(result.unmatched).toEqual([]); | ||
| 208 | }); | ||
| 209 | |||
| 210 | it("a root-domain-only match lands in tier 2 (nearMatches), not tier 1", () => { | ||
| 211 | // The declared endpoint is api.cloudflare.com; the app is on mcp.cloudflare.com. | ||
| 212 | // Same registrable root (cloudflare.com), different host, no intent stamp: this | ||
| 213 | // is exactly the bug case — it must NOT be presented as a real match. | ||
| 214 | const nearMiss = app("cloudflare-mcp-app", { | ||
| 215 | authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| 216 | tokenUrl: "https://mcp.cloudflare.com/token", | ||
| 217 | }); | ||
| 218 | const result = selectClientsForEndpoints([nearMiss], { | ||
| 219 | tokenUrl: "https://api.cloudflare.com/client/v4/token", | ||
| 220 | authorizationUrl: "https://api.cloudflare.com/client/v4/authorize", | ||
| 221 | integration: IntegrationSlug.make("cloudflare_api"), | ||
| 222 | }); | ||
| 223 | expect(result.endpointMatched).toBe(false); | ||
| 224 | expect(result.matched).toEqual([]); | ||
| 225 | expect(result.nearMatches.map((a: OAuthClientOption) => String(a.slug))).toEqual([ | ||
| 226 | "cloudflare-mcp-app", | ||
| 227 | ]); | ||
| 228 | expect(result.unmatched).toEqual([]); | ||
| 229 | }); | ||
| 230 | |||
| 231 | it("exact endpoint host is a tier-1 match even without an intent stamp", () => { | ||
| 232 | const exact = app("cloudflare-api-app", { | ||
| 233 | authorizationUrl: "https://api.cloudflare.com/client/v4/authorize", | ||
| 234 | tokenUrl: "https://api.cloudflare.com/client/v4/token", | ||
| 235 | }); | ||
| 236 | const result = selectClientsForEndpoints([exact], { | ||
| 237 | tokenUrl: "https://api.cloudflare.com/client/v4/token", | ||
| 238 | integration: IntegrationSlug.make("cloudflare_api"), | ||
| 239 | }); | ||
| 240 | expect(result.endpointMatched).toBe(true); | ||
| 241 | expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ | ||
| 242 | "cloudflare-api-app", | ||
| 243 | ]); | ||
| 244 | expect(result.nearMatches).toEqual([]); | ||
| 245 | }); | ||
| 246 | |||
| 247 | it("recorded intent matches even when the integration declares no endpoints (requireEndpointMatch)", () => { | ||
| 248 | const integration = IntegrationSlug.make("linear_mcp"); | ||
| 249 | const stamped = app("linear-mcp-app", { | ||
| 250 | authorizationUrl: "https://linear.app/oauth/authorize", | ||
| 251 | tokenUrl: "https://api.linear.app/oauth/token", | ||
| 252 | origin: { kind: "manual", integration }, | ||
| 253 | }); | ||
| 254 | const unrelated = app("spotify-app-2", { | ||
| 255 | authorizationUrl: "https://accounts.spotify.com/authorize", | ||
| 256 | tokenUrl: "https://accounts.spotify.com/api/token", | ||
| 257 | }); | ||
| 258 | const result = selectClientsForEndpoints([unrelated, stamped], { | ||
| 259 | requireEndpointMatch: true, | ||
| 260 | integration, | ||
| 261 | }); | ||
| 262 | expect(result.endpointMatched).toBe(true); | ||
| 263 | expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ | ||
| 264 | "linear-mcp-app", | ||
| 265 | ]); | ||
| 266 | expect(result.unmatched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ | ||
| 267 | "spotify-app-2", | ||
| 268 | ]); | ||
| 269 | }); | ||
| 270 | }); | ||
| 271 | |||
| 272 | describe("selectDcrClientsForIntegration", () => { | ||
| 273 | const integration = IntegrationSlug.make("linear_mcp"); | ||
| 274 | const dcrStamped = app("dcr-auth-linear-app", { | ||
| 275 | authorizationUrl: "https://linear.app/oauth/authorize", | ||
| 276 | tokenUrl: "https://api.linear.app/oauth/token", | ||
| 277 | origin: { kind: "dynamic_client_registration", integration }, | ||
| 278 | }); | ||
| 279 | const dcrOtherProvider = app("dcr-mcp-cloudflare-com", { | ||
| 280 | authorizationUrl: "https://mcp.cloudflare.com/authorize", | ||
| 281 | tokenUrl: "https://mcp.cloudflare.com/token", | ||
| 282 | origin: { kind: "dynamic_client_registration", integration: null }, | ||
| 283 | }); | ||
| 284 | const manualLinear = app("linear-manual", { | ||
| 285 | authorizationUrl: "https://linear.app/oauth/authorize", | ||
| 286 | tokenUrl: "https://api.linear.app/oauth/token", | ||
| 287 | origin: { kind: "manual", integration }, | ||
| 288 | }); | ||
| 289 | |||
| 290 | it("returns only the DCR clients stamped with this integration", () => { | ||
| 291 | const result = selectDcrClientsForIntegration([dcrStamped, dcrOtherProvider, manualLinear], { | ||
| 292 | integration, | ||
| 293 | }); | ||
| 294 | expect(result.map((a: OAuthClientOption) => String(a.slug))).toEqual(["dcr-auth-linear-app"]); | ||
| 295 | }); | ||
| 296 | |||
| 297 | it("surfaces legacy unstamped DCR clients by root domain of the declared endpoint", () => { | ||
| 298 | const legacyUnstamped = app("dcr-api-linear-app", { | ||
| 299 | authorizationUrl: "https://linear.app/oauth/authorize", | ||
| 300 | tokenUrl: "https://api.linear.app/oauth/token", | ||
| 301 | origin: { kind: "dynamic_client_registration", integration: null }, | ||
| 302 | }); | ||
| 303 | const result = selectDcrClientsForIntegration([legacyUnstamped, dcrOtherProvider], { | ||
| 304 | integration, | ||
| 305 | tokenUrl: "https://api.linear.app/oauth/token", | ||
| 306 | }); | ||
| 307 | expect(result.map((a: OAuthClientOption) => String(a.slug))).toEqual(["dcr-api-linear-app"]); | ||
| 308 | }); | ||
| 309 | |||
| 310 | it("never returns manual apps", () => { | ||
| 311 | const result = selectDcrClientsForIntegration([manualLinear], { | ||
| 312 | integration, | ||
| 313 | tokenUrl: "https://api.linear.app/oauth/token", | ||
| 314 | }); | ||
| 315 | expect(result).toEqual([]); | ||
| 316 | }); | ||
| 136 | }); | 317 | }); |
| 137 | 318 | ||
| 138 | describe("uniqueClientSlug", () => { | 319 | describe("uniqueClientSlug", () => { |
| @@ -144,3 +325,14 @@ describe("uniqueClientSlug", () => { · 5 unmodified lines | |||
| 144 | ); | 325 | ); |
| 145 | }); | 326 | }); |
| 146 | }); | 327 | }); |
| 328 | |||
| 329 | describe("optimisticDcrClientSlug", () => { | ||
| 330 | it("derives a deterministic optimistic DCR slug from the authorization server host", () => { | ||
| 331 | expect(String(optimisticDcrClientSlug("https://mcp.cloudflare.com/register"))).toBe( | ||
| 332 | "dcr-mcp-cloudflare-com", | ||
| 333 | ); | ||
| 334 | expect(String(optimisticDcrClientSlug("http://127.0.0.1:8787/register"))).toBe( | ||
| 335 | "dcr-127-0-0-1-8787", | ||
| 336 | ); | ||
| 337 | }); | ||
| 338 | }); | ||
| @@ -1,6 +1,12 @@ | |||
| 1 | import { useMemo } from "react"; | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 3 | import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; | 4 | import { |
| 5 | OAuthClientSlug, | ||
| 6 | type IntegrationSlug, | ||
| 7 | type OAuthClientOrigin, | ||
| 8 | type Owner, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 4 | import { getDomain } from "tldts"; | 10 | import { getDomain } from "tldts"; |
| 5 | 11 | ||
| 6 | import { oauthClientsOptimisticAtom } from "../api/atoms"; | 12 | import { oauthClientsOptimisticAtom } from "../api/atoms"; |
| @@ -9,12 +15,17 @@ import { oauthClientsOptimisticAtom } from "../api/atoms"; · 2 unmodified lines | |||
| 9 | // OAuth client (registered app) selection for an integration's connect flow. | 15 | // OAuth client (registered app) selection for an integration's connect flow. |
| 10 | // | 16 | // |
| 11 | // An owner can register MANY apps; each is a distinct owner-scoped `oauth_client` | 17 | // An owner can register MANY apps; each is a distinct owner-scoped `oauth_client` |
| 12 | // row with its own slug. The connect flow lists the apps usable for an | 18 | // row with its own slug. The connect flow lists the MANUAL apps usable for an |
| 13 | // integration (owner-visible clients whose OAuth endpoints share a registrable | 19 | // integration and lets the user pick one or register a new one. Matching is by |
| 14 | // root domain with the integration's declared endpoints when known) and lets the | 20 | // recorded intent first (an app registered from this integration's dialog, or |
| 15 | // user pick one or register a new one. User-owned apps are listed before | 21 | // whose OAuth endpoint HOST exactly matches the integration's declared host), |
| 16 | // workspace ones. When NOTHING matches a declared endpoint, the picker shows an | 22 | // then by registrable root domain as a subdued near-miss tier. User-owned apps |
| 17 | // empty state + a "register an app" CTA rather than unrelated providers' apps. | 23 | // are listed before workspace ones. |
| 24 | // | ||
| 25 | // DCR (dynamic client registration) clients are plumbing, not apps: they are | ||
| 26 | // minted automatically and reused per authorization server, so they NEVER appear | ||
| 27 | // in this picker. They stay visible for management elsewhere (the modal's | ||
| 28 | // "Auto-registered clients" surface), keyed off `origin.kind`. | ||
| 18 | // --------------------------------------------------------------------------- | 29 | // --------------------------------------------------------------------------- |
| 19 | 30 | ||
| 20 | export interface OAuthClientOption { | 31 | export interface OAuthClientOption { |
| @@ -24,8 +35,18 @@ export interface OAuthClientOption { · 3 unmodified lines | |||
| 24 | readonly authorizationUrl: string; | 35 | readonly authorizationUrl: string; |
| 25 | readonly tokenUrl: string; | 36 | readonly tokenUrl: string; |
| 26 | readonly clientId: string; | 37 | readonly clientId: string; |
| 38 | /** How the app came to exist. `manual` apps are pickable (with `integration` | ||
| 39 | * recording which dialog registered them); `dynamic_client_registration` | ||
| 40 | * apps are excluded from the picker entirely. */ | ||
| 41 | readonly origin: OAuthClientOrigin; | ||
| 27 | } | 42 | } |
| 28 | 43 | ||
| 44 | /** True for the auto-minted DCR clients that must never surface in the app | ||
| 45 | * picker. Legacy null-origin rows are already classified by the backend's | ||
| 46 | * `parseOAuthClientOrigin` heuristic, so this reads the parsed kind directly. */ | ||
| 47 | export const isDcrClient = (app: OAuthClientOption): boolean => | ||
| 48 | app.origin.kind === "dynamic_client_registration"; | ||
| 49 | |||
| 29 | const hostOf = (url: string): string | undefined => { | 50 | const hostOf = (url: string): string | undefined => { |
| 30 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" | 51 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" |
| 31 | try { | 52 | try { |
| @@ -46,35 +67,54 @@ const getRootDomain = (url: string): string | undefined => { · 14 unmodified lines | |||
| 46 | }; | 67 | }; |
| 47 | 68 | ||
| 48 | export interface UseOAuthClientsResult { | 69 | export interface UseOAuthClientsResult { |
| 49 | /** Apps usable for this integration, user-owned first. When an endpoint was | 70 | /** Tier 1 — apps that match this integration by RECORDED INTENT (registered |
| 50 | * declared but nothing matched, this is EMPTY (the unmatched apps move to | 71 | * from its dialog) or EXACT endpoint host, user-owned first. These are the |
| 51 | * `otherClients`). */ | 72 | * apps that belong to this integration; the picker defaults to the first one. |
| 73 | * When an endpoint was declared but nothing matched exactly, this is EMPTY. */ | ||
| 52 | readonly clients: readonly OAuthClientOption[]; | 74 | readonly clients: readonly OAuthClientOption[]; |
| 53 | /** Unmatched owner-visible apps — surfaced only as an opt-in escape hatch | 75 | /** Tier 2 — apps that only match by registrable root domain (a near-miss: same |
| 54 | * ("use a different registered app") when no app matched the declared | 76 | * provider family, different exact host / not stamped to this integration). |
| 55 | * endpoint. Empty when `endpointMatched` is true. */ | 77 | * Surfaced in a visually separate, subdued "Other apps on this provider" |
| 78 | * section — NEVER silently mixed into `clients`. Present whenever there are | ||
| 79 | * such near-misses, even when tier 1 is non-empty. */ | ||
| 80 | readonly nearMatches: readonly OAuthClientOption[]; | ||
| 81 | /** Unrelated apps (different root domain), kept only as the opt-in escape hatch | ||
| 82 | * ("use a different registered app") when NOTHING matched the declared | ||
| 83 | * endpoint at all. Empty once anything in tier 1 or tier 2 matched. */ | ||
| 56 | readonly otherClients: readonly OAuthClientOption[]; | 84 | readonly otherClients: readonly OAuthClientOption[]; |
| 57 | /** True until the clients list has loaded at least once. */ | 85 | /** True until the clients list has loaded at least once. */ |
| 58 | readonly loading: boolean; | 86 | readonly loading: boolean; |
| 59 | /** | 87 | /** |
| 60 | * Whether the returned `clients` are matched to the integration's declared | 88 | * Whether an app is EXACT-matched (tier 1) to the integration. |
| 61 | * OAuth endpoints (by registrable root domain across authorize + token). | ||
| 62 | * | 89 | * |
| 63 | * - `true` — either no endpoint filter was requested (the integration | 90 | * - `true` — either no endpoint filter was requested (the integration |
| 64 | * declares no endpoints), or at least one registered app's authorize/token | 91 | * declares no endpoints), or at least one app matched by recorded intent or |
| 65 | * root domain matched. `clients` are the matched subset. | 92 | * exact endpoint host. `clients` is that tier-1 subset. |
| 66 | * - `false` — the integration declared endpoint(s) but NO registered app | 93 | * - `false` — the integration declared endpoint(s) but no app matched exactly. |
| 67 | * matched. `clients` is then EMPTY (so the UI shows an empty state + a | 94 | * `clients` is EMPTY (the UI shows an empty state + a register CTA). Any |
| 68 | * register CTA rather than unrelated providers' apps), and the unmatched | 95 | * root-domain near-misses are still offered via `nearMatches`; truly |
| 69 | * apps are surfaced separately in `otherClients` for the opt-in escape hatch. | 96 | * unrelated apps via `otherClients`. |
| 70 | */ | 97 | */ |
| 71 | readonly endpointMatched: boolean; | 98 | readonly endpointMatched: boolean; |
| 72 | /** Convenience flag for the UI: a register-an-app CTA should be shown because | 99 | /** Convenience flag for the UI: a register-an-app CTA should be shown because |
| 73 | * an endpoint was declared and nothing matched. Equals `!endpointMatched` | 100 | * an endpoint was declared and nothing exact-matched. Equals `!endpointMatched` |
| 74 | * once loaded. */ | 101 | * once loaded. */ |
| 75 | readonly displayRegisterCTA: boolean; | 102 | readonly displayRegisterCTA: boolean; |
| 76 | } | 103 | } |
| 77 | 104 | ||
| 105 | /** Stable empty-list reference so the picker memo doesn't re-run while the | ||
| 106 | * optimistic clients atom is still loading (a fresh `[]` each render would | ||
| 107 | * invalidate the memo key). */ | ||
| 108 | const EMPTY_CLIENTS: readonly OAuthClientOption[] = []; | ||
| 109 | |||
| 110 | /** Host/root equality that matches ONLY when both sides parsed to a real value. | ||
| 111 | * `hostOf`/`getRootDomain` return undefined for URLs `new URL()` can't parse, | ||
| 112 | * so a bare `a === b` would treat two unparseable endpoints as equal | ||
| 113 | * (`undefined === undefined`). Every host/root comparison in this module must | ||
| 114 | * go through this so an unparseable value never counts as a match. */ | ||
| 115 | const hostEq = (a: string | undefined, b: string | undefined): boolean => | ||
| 116 | a !== undefined && b !== undefined && a === b; | ||
| 117 | |||
| 78 | /** Sort apps user-owned first (so the user's own apps surface before shared | 118 | /** Sort apps user-owned first (so the user's own apps surface before shared |
| 79 | * workspace apps). */ | 119 | * workspace apps). */ |
| 80 | const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] => | 120 | const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] => |
| @@ -83,23 +123,37 @@ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClient · 2 unmodified lines | |||
| 83 | ); | 123 | ); |
| 84 | 124 | ||
| 85 | /** | 125 | /** |
| 86 | * Pure matcher (no React/atoms) — split owner-visible apps into the ones that | 126 | * Pure matcher (no React/atoms) — split owner-visible apps into three honest |
| 87 | * match the integration's declared OAuth endpoints and the ones that don't. | 127 | * tiers for an integration's connect picker. |
| 128 | * | ||
| 129 | * FIRST, DCR clients are dropped entirely: they are auto-minted plumbing reused | ||
| 130 | * per authorization server, not apps a user picks. They stay manageable through | ||
| 131 | * a separate surface, never the picker. | ||
| 88 | * | 132 | * |
| 89 | * Matching is by REGISTRABLE ROOT DOMAIN ("tld+1"). When the integration | 133 | * Then the remaining MANUAL apps are graded: |
| 90 | * declares a token endpoint, an app must match by token endpoint root; the token | 134 | * - `matched` (tier 1): the app was registered from THIS integration's dialog |
| 91 | * endpoint is what the SDK will call during code exchange/refresh and avoids | 135 | * (`origin.integration === integration`, recorded intent), OR its OAuth |
| 92 | * authorize-root coincidences. When only an authorization endpoint is declared, | 136 | * endpoint HOST exactly matches the integration's declared host (token host |
| 93 | * the authorize root is used as the fallback compatibility signal. Unrelated | 137 | * when a token endpoint is declared, else the authorize host). This is the |
| 94 | * providers (different root) never match. | 138 | * subset that truly belongs to the integration. |
| 139 | * - `nearMatches` (tier 2): the app shares the integration's REGISTRABLE ROOT | ||
| 140 | * DOMAIN ("tld+1") but is not a tier-1 match — a same-provider near-miss (e.g. | ||
| 141 | * an app on `mcp.cloudflare.com` for a REST integration on `api.cloudflare.com`). | ||
| 142 | * Silently promoting these into tier 1 is the bug this change fixes. | ||
| 143 | * - `unmatched`: unrelated provider (different root), offered only as the escape | ||
| 144 | * hatch when nothing matched at all. | ||
| 95 | * | 145 | * |
| 96 | * When the integration declares no endpoints, every app is "matched" (no filter). | 146 | * When the integration declares no endpoints (and no match is required), every |
| 147 | * manual app is tier 1 (no filter to grade against). | ||
| 97 | */ | 148 | */ |
| 98 | export function selectClientsForEndpoints( | 149 | export function selectClientsForEndpoints( |
| 99 | all: readonly OAuthClientOption[], | 150 | all: readonly OAuthClientOption[], |
| 100 | endpoints: { | 151 | endpoints: { |
| 101 | readonly tokenUrl?: string; | 152 | readonly tokenUrl?: string; |
| 102 | readonly authorizationUrl?: string; | 153 | readonly authorizationUrl?: string; |
| 154 | /** The integration whose picker this is. A manual app stamped with this | ||
| 155 | * integration (recorded intent) is a tier-1 match regardless of host. */ | ||
| 156 | readonly integration?: IntegrationSlug; | ||
| 103 | /** When set, an integration that targets a SPECIFIC server (MCP, whose | 157 | /** When set, an integration that targets a SPECIFIC server (MCP, whose |
| 104 | * endpoints are discovered at connect) must match by endpoint — absent | 158 | * endpoints are discovered at connect) must match by endpoint — absent |
| 105 | * endpoints mean NO match (show the register CTA), never "every app | 159 | * endpoints mean NO match (show the register CTA), never "every app |
| @@ -108,36 +162,81 @@ export function selectClientsForEndpoints( · 2 unmodified lines | |||
| 108 | }, | 162 | }, |
| 109 | ): { | 163 | ): { |
| 110 | readonly matched: readonly OAuthClientOption[]; | 164 | readonly matched: readonly OAuthClientOption[]; |
| 165 | readonly nearMatches: readonly OAuthClientOption[]; | ||
| 111 | readonly unmatched: readonly OAuthClientOption[]; | 166 | readonly unmatched: readonly OAuthClientOption[]; |
| 112 | readonly endpointMatched: boolean; | 167 | readonly endpointMatched: boolean; |
| 113 | } { | 168 | } { |
| 169 | // DCR clients are plumbing, never picker options. | ||
| 170 | const manual = all.filter((app) => !isDcrClient(app)); | ||
| 171 | |||
| 172 | const intent = endpoints.integration; | ||
| 173 | const matchesIntent = (app: OAuthClientOption): boolean => | ||
| 174 | intent != null && | ||
| 175 | app.origin.kind === "manual" && | ||
| 176 | app.origin.integration != null && | ||
| 177 | app.origin.integration === intent; | ||
| 178 | |||
| 179 | const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined; | ||
| 180 | const wantedAuthorizationHost = endpoints.authorizationUrl | ||
| 181 | ? hostOf(endpoints.authorizationUrl) | ||
| 182 | : undefined; | ||
| 114 | const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined; | 183 | const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined; |
| 115 | const wantedAuthorizationRoot = endpoints.authorizationUrl | 184 | const wantedAuthorizationRoot = endpoints.authorizationUrl |
| 116 | ? getRootDomain(endpoints.authorizationUrl) | 185 | ? getRootDomain(endpoints.authorizationUrl) |
| 117 | : undefined; | 186 | : undefined; |
| 187 | |||
| 118 | // No declared endpoints. A server-targeting integration must NOT match every | 188 | // No declared endpoints. A server-targeting integration must NOT match every |
| 119 | // app (it would auto-select an unrelated provider); surface the register CTA | 189 | // app (it would auto-select an unrelated provider); surface the register CTA |
| 120 | // instead. Otherwise (no endpoint filter at all) every app is usable. | 190 | // instead. Otherwise (no endpoint filter at all) every manual app is usable. |
| 121 | if (!wantedTokenRoot && !wantedAuthorizationRoot) { | 191 | if (!wantedTokenRoot && !wantedAuthorizationRoot) { |
| 122 | if (endpoints.requireEndpointMatch) { | 192 | if (endpoints.requireEndpointMatch) { |
| 123 | return { matched: [], unmatched: sortUserFirst(all), endpointMatched: false }; | 193 | // Even with no declared endpoints, an app registered from THIS dialog is a |
| 194 | // real tier-1 match (the user built it here on purpose). | ||
| 195 | const matched = manual.filter(matchesIntent); | ||
| 196 | const rest = manual.filter((app) => !matchesIntent(app)); | ||
| 197 | return { | ||
| 198 | matched: sortUserFirst(matched), | ||
| 199 | nearMatches: [], | ||
| 200 | unmatched: sortUserFirst(rest), | ||
| 201 | endpointMatched: matched.length > 0, | ||
| 202 | }; | ||
| 124 | } | 203 | } |
| 125 | return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true }; | 204 | return { |
| 205 | matched: sortUserFirst(manual), | ||
| 206 | nearMatches: [], | ||
| 207 | unmatched: [], | ||
| 208 | endpointMatched: true, | ||
| 209 | }; | ||
| 126 | } | 210 | } |
| 211 | |||
| 127 | const matched: OAuthClientOption[] = []; | 212 | const matched: OAuthClientOption[] = []; |
| 213 | const nearMatches: OAuthClientOption[] = []; | ||
| 128 | const unmatched: OAuthClientOption[] = []; | 214 | const unmatched: OAuthClientOption[] = []; |
| 129 | for (const app of all) { | 215 | for (const app of manual) { |
| 216 | const appTokenHost = hostOf(app.tokenUrl); | ||
| 217 | const appAuthorizationHost = hostOf(app.authorizationUrl); | ||
| 130 | const appTokenRoot = getRootDomain(app.tokenUrl); | 218 | const appTokenRoot = getRootDomain(app.tokenUrl); |
| 131 | const appAuthorizationRoot = getRootDomain(app.authorizationUrl); | 219 | const appAuthorizationRoot = getRootDomain(app.authorizationUrl); |
| 132 | const fits = wantedTokenRoot | 220 | // Exact HOST match against the declared endpoint (token host first, since the |
| 133 | ? appTokenRoot === wantedTokenRoot | 221 | // token endpoint is what the SDK actually calls; authorize host as fallback |
| 134 | : appAuthorizationRoot === wantedAuthorizationRoot || | 222 | // when no token endpoint was declared). |
| 135 | appTokenRoot === wantedAuthorizationRoot; | 223 | const exactHostMatch = wantedTokenHost |
| 136 | if (fits) matched.push(app); | 224 | ? hostEq(appTokenHost, wantedTokenHost) |
| 225 | : hostEq(appAuthorizationHost, wantedAuthorizationHost) || | ||
| 226 | hostEq(appTokenHost, wantedAuthorizationHost); | ||
| 227 | // Same registrable root domain (the old, looser heuristic) — now only a | ||
| 228 | // tier-2 signal. | ||
| 229 | const rootMatch = wantedTokenRoot | ||
| 230 | ? hostEq(appTokenRoot, wantedTokenRoot) | ||
| 231 | : hostEq(appAuthorizationRoot, wantedAuthorizationRoot) || | ||
| 232 | hostEq(appTokenRoot, wantedAuthorizationRoot); | ||
| 233 | if (matchesIntent(app) || exactHostMatch) matched.push(app); | ||
| 234 | else if (rootMatch) nearMatches.push(app); | ||
| 137 | else unmatched.push(app); | 235 | else unmatched.push(app); |
| 138 | } | 236 | } |
| 139 | return { | 237 | return { |
| 140 | matched: sortUserFirst(matched), | 238 | matched: sortUserFirst(matched), |
| 239 | nearMatches: sortUserFirst(nearMatches), | ||
| 141 | unmatched: sortUserFirst(unmatched), | 240 | unmatched: sortUserFirst(unmatched), |
| 142 | endpointMatched: matched.length > 0, | 241 | endpointMatched: matched.length > 0, |
| 143 | }; | 242 | }; |
| @@ -146,6 +245,7 @@ export function selectClientsForEndpoints( · 2 unmodified lines | |||
| 146 | export function useOAuthClientsForIntegration(opts: { | 245 | export function useOAuthClientsForIntegration(opts: { |
| 147 | readonly tokenUrl?: string; | 246 | readonly tokenUrl?: string; |
| 148 | readonly authorizationUrl?: string; | 247 | readonly authorizationUrl?: string; |
| 248 | readonly integration?: IntegrationSlug; | ||
| 149 | readonly requireEndpointMatch?: boolean; | 249 | readonly requireEndpointMatch?: boolean; |
| 150 | }): UseOAuthClientsResult { | 250 | }): UseOAuthClientsResult { |
| 151 | // Read the optimistic list so a just-registered/edited/removed app paints | 251 | // Read the optimistic list so a just-registered/edited/removed app paints |
| @@ -153,9 +253,29 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line | |||
| 153 | // lands. The modal's management menu reads the same optimistic atom, so the | 253 | // lands. The modal's management menu reads the same optimistic atom, so the |
| 154 | // picker rows and their actions stay consistent. | 254 | // picker rows and their actions stay consistent. |
| 155 | const clientsResult = useAtomValue(oauthClientsOptimisticAtom); | 255 | const clientsResult = useAtomValue(oauthClientsOptimisticAtom); |
| 156 | if (!AsyncResult.isSuccess(clientsResult)) { | 256 | const loaded = AsyncResult.isSuccess(clientsResult); |
| 257 | const all = loaded ? (clientsResult.value as readonly OAuthClientOption[]) : EMPTY_CLIENTS; | ||
| 258 | |||
| 259 | // Memoize the grade: `selectClientsForEndpoints` parses every client's URLs | ||
| 260 | // with tldts, and the modal passes a FRESH inline options object each render, | ||
| 261 | // so without this every keystroke would re-grade all clients. Key on the | ||
| 262 | // primitive inputs plus the `all` array reference (a new optimistic list is a | ||
| 263 | // new array), NOT on `opts` identity. | ||
| 264 | const selection = useMemo( | ||
| 265 | () => | ||
| 266 | selectClientsForEndpoints(all, { | ||
| 267 | tokenUrl: opts.tokenUrl, | ||
| 268 | authorizationUrl: opts.authorizationUrl, | ||
| 269 | integration: opts.integration, | ||
| 270 | requireEndpointMatch: opts.requireEndpointMatch, | ||
| 271 | }), | ||
| 272 | [all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.requireEndpointMatch], | ||
| 273 | ); | ||
| 274 | |||
| 275 | if (!loaded) { | ||
| 157 | return { | 276 | return { |
| 158 | clients: [], | 277 | clients: [], |
| 278 | nearMatches: [], | ||
| 159 | otherClients: [], | 279 | otherClients: [], |
| 160 | loading: true, | 280 | loading: true, |
| 161 | endpointMatched: true, | 281 | endpointMatched: true, |
| @@ -163,19 +283,16 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line | |||
| 163 | }; | 283 | }; |
| 164 | } | 284 | } |
| 165 | 285 | ||
| 166 | const all = clientsResult.value as readonly OAuthClientOption[]; | 286 | const { matched, nearMatches, unmatched, endpointMatched } = selection; |
| 167 | const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, { | 287 | // EXPLICIT outcome: `clients` is the tier-1 (exact/intent) subset the picker |
| 168 | tokenUrl: opts.tokenUrl, | 288 | // defaults into. `nearMatches` (root-domain-only) is always surfaced, but in a |
| 169 | authorizationUrl: opts.authorizationUrl, | 289 | // separate subdued section so it is never mistaken for a real match. When an |
| 170 | requireEndpointMatch: opts.requireEndpointMatch, | 290 | // endpoint was declared but nothing exact-matched, `clients` is EMPTY and a |
| 171 | }); | 291 | // register CTA is flagged; the unrelated `unmatched` apps stay in `otherClients` |
| 172 | // EXPLICIT outcome: when at least one app matched (or no endpoint was | 292 | // as the last-resort escape hatch. |
| 173 | // declared) we present the matched subset. When an endpoint was declared but | ||
| 174 | // nothing matched, `clients` is EMPTY — the unmatched apps move to | ||
| 175 | // `otherClients` for an opt-in escape hatch — and we flag a register CTA so | ||
| 176 | // the UI offers "register an app" instead of surfacing unrelated providers. | ||
| 177 | return { | 293 | return { |
| 178 | clients: endpointMatched ? matched : [], | 294 | clients: endpointMatched ? matched : [], |
| 295 | nearMatches, | ||
| 179 | otherClients: endpointMatched ? [] : unmatched, | 296 | otherClients: endpointMatched ? [] : unmatched, |
| 180 | loading: false, | 297 | loading: false, |
| 181 | endpointMatched, | 298 | endpointMatched, |
| @@ -183,6 +300,49 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line | |||
| 183 | }; | 300 | }; |
| 184 | } | 301 | } |
| 185 | 302 | ||
| 303 | /** | ||
| 304 | * The auto-registered (DCR) clients relevant to an integration, for the | ||
| 305 | * management surface (list + delete). These are hidden from the app picker but | ||
| 306 | * must stay visible SOMEWHERE. A DCR client is relevant when it records this | ||
| 307 | * integration as its origin (Part A stamps `origin.integration`), or — for | ||
| 308 | * legacy clients minted before the stamp — when its endpoints share the | ||
| 309 | * integration's declared/discovered root domain. | ||
| 310 | */ | ||
| 311 | export function selectDcrClientsForIntegration( | ||
| 312 | all: readonly OAuthClientOption[], | ||
| 313 | opts: { | ||
| 314 | readonly integration?: IntegrationSlug; | ||
| 315 | readonly tokenUrl?: string; | ||
| 316 | readonly authorizationUrl?: string; | ||
| 317 | }, | ||
| 318 | ): readonly OAuthClientOption[] { | ||
| 319 | const wantedTokenRoot = opts.tokenUrl ? getRootDomain(opts.tokenUrl) : undefined; | ||
| 320 | const wantedAuthorizationRoot = opts.authorizationUrl | ||
| 321 | ? getRootDomain(opts.authorizationUrl) | ||
| 322 | : undefined; | ||
| 323 | const relevant = all.filter((app) => { | ||
| 324 | if (!isDcrClient(app)) return false; | ||
| 325 | if ( | ||
| 326 | opts.integration != null && | ||
| 327 | app.origin.kind === "dynamic_client_registration" && | ||
| 328 | app.origin.integration != null && | ||
| 329 | app.origin.integration === opts.integration | ||
| 330 | ) { | ||
| 331 | return true; | ||
| 332 | } | ||
| 333 | if (!wantedTokenRoot && !wantedAuthorizationRoot) return false; | ||
| 334 | const appTokenRoot = getRootDomain(app.tokenUrl); | ||
| 335 | const appAuthorizationRoot = getRootDomain(app.authorizationUrl); | ||
| 336 | return ( | ||
| 337 | hostEq(appTokenRoot, wantedTokenRoot) || | ||
| 338 | hostEq(appAuthorizationRoot, wantedTokenRoot) || | ||
| 339 | hostEq(appAuthorizationRoot, wantedAuthorizationRoot) || | ||
| 340 | hostEq(appTokenRoot, wantedAuthorizationRoot) | ||
| 341 | ); | ||
| 342 | }); | ||
| 343 | return sortUserFirst(relevant); | ||
| 344 | } | ||
| 345 | |||
| 186 | const slugifyName = (name: string): string => | 346 | const slugifyName = (name: string): string => |
| 187 | name | 347 | name |
| 188 | .toLowerCase() | 348 | .toLowerCase() |
| @@ -199,6 +359,24 @@ export function uniqueClientSlug(name: string, existing: readonly string[]): OAu · 10 unmodified lines | |||
| 199 | return OAuthClientSlug.make(`${base}-${suffix}`); | 359 | return OAuthClientSlug.make(`${base}-${suffix}`); |
| 200 | } | 360 | } |
| 201 | 361 | ||
| 362 | /** | ||
| 363 | * Optimistic (client-side) DCR client slug derived from the authorization | ||
| 364 | * server host, for immediate/placeholder display only. | ||
| 365 | * | ||
| 366 | * The AUTHORITATIVE slug is computed server-side by `dcrClientSlug` in | ||
| 367 | * `packages/core/sdk/src/oauth-service.ts`, which is resource-aware (an AS | ||
| 368 | * serving multiple RFC 8707 resources gets distinct, hash-suffixed slugs). This | ||
| 369 | * host-only form deliberately ignores resource: it is a best-effort placeholder | ||
| 370 | * the UI shows before the server responds, and the server recomputes and | ||
| 371 | * persists the real slug on registration. Do NOT rely on it matching the stored | ||
| 372 | * slug for a multi-resource server. | ||
| 373 | */ | ||
| 374 | export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSlug { | ||
| 375 | const host = hostOf(issuerOrEndpoint); | ||
| 376 | const base = host === undefined ? "" : slugifyName(host); | ||
| 377 | return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`); | ||
| 378 | } | ||
| 379 | |||
| 202 | /** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */ | 380 | /** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */ |
| 203 | export function clientDisplayName(slug: string): string { | 381 | export function clientDisplayName(slug: string): string { |
| 204 | const text = slug.replace(/[-_]/g, " ").trim(); | 382 | const text = slug.replace(/[-_]/g, " ").trim(); |
| @@ -1,8 +1,33 @@ | |||
| 1 | diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts | ||
| 2 | index c8fad448e8797b89690a99d93490d1363851b225..439da01e789713d217778e8bdebfa1a9d52a71e2 100644 | ||
| 3 | --- a/dist/mcp/index.d.ts | ||
| 4 | +++ b/dist/mcp/index.d.ts | ||
| 5 | @@ -29,6 +29,7 @@ import { | ||
| 6 | xt as MCPClientOAuthCallbackConfig, | ||
| 7 | zt as ElicitResult | ||
| 8 | } from "../agent-tool-types-CNyE1iz_.js"; | ||
| 9 | +declare const MAX_SSE_AGE_MS = 300000; | ||
| 10 | export { | ||
| 11 | type ClearableEventStore, | ||
| 12 | type CreateMcpHandlerOptions, | ||
| 13 | @@ -41,6 +42,7 @@ export { | ||
| 14 | type MCPConnectionResult, | ||
| 15 | type MCPDiscoverResult, | ||
| 16 | type MCPServerOptions, | ||
| 17 | + MAX_SSE_AGE_MS, | ||
| 18 | MCP_SERVER_ID_MAX_LENGTH, | ||
| 19 | McpAgent, | ||
| 20 | type McpAuthContext, | ||
| 1 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js | 21 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js |
| 2 | index 1edcf0c8..e65cd346 100644 | 22 | index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..0ba4f5bff8e6a968aa18c609952e576cedf13d8f 100644 |
| 3 | --- a/dist/mcp/index.js | 23 | --- a/dist/mcp/index.js |
| 4 | +++ b/dist/mcp/index.js | 24 | +++ b/dist/mcp/index.js |
| 5 | @@ -32,9 +32,9 @@ const KEEPALIVE_FRAME = ": keepalive\n\n"; | 25 | @@ -28,13 +28,14 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ |
| 26 | const KEEPALIVE_INTERVAL_MS = 25e3; | ||
| 27 | /** SSE comment frame the parser drops before any event dispatch. */ | ||
| 28 | const KEEPALIVE_FRAME = ": keepalive\n\n"; | ||
| 29 | +const MAX_SSE_AGE_MS = 300_000; | ||
| 30 | /** | ||
| 6 | * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle | 31 | * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle |
| 7 | * that the stream cleanup must invoke when the stream closes. | 32 | * that the stream cleanup must invoke when the stream closes. |
| 8 | */ | 33 | */ |
| @@ -14,7 +39,7 @@ index 1edcf0c8..e65cd346 100644 · 5 unmodified lines | |||
| 14 | }, KEEPALIVE_INTERVAL_MS); | 39 | }, KEEPALIVE_INTERVAL_MS); |
| 15 | return handle; | 40 | return handle; |
| 16 | } | 41 | } |
| 17 | @@ -180,10 +180,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | 42 | @@ -180,10 +181,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { |
| 18 | }); | 43 | }); |
| 19 | return new Response(body, { status: 404 }); | 44 | return new Response(body, { status: 404 }); |
| 20 | } | 45 | } |
| @@ -34,7 +59,7 @@ index 1edcf0c8..e65cd346 100644 · 13 unmodified lines | |||
| 34 | request.headers.forEach((value, key) => { | 59 | request.headers.forEach((value, key) => { |
| 35 | existingHeaders[key] = value; | 60 | existingHeaders[key] = value; |
| 36 | }); | 61 | }); |
| 37 | @@ -206,45 +211,68 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | 62 | @@ -206,45 +212,71 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { |
| 38 | jsonrpc: "2.0" | 63 | jsonrpc: "2.0" |
| 39 | }); | 64 | }); |
| 40 | return new Response(body, { status: 500 }); | 65 | return new Response(body, { status: 500 }); |
| @@ -45,17 +70,20 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines | |||
| 45 | - return new Response(null, { | 70 | - return new Response(null, { |
| 46 | + } | 71 | + } |
| 47 | + ws.accept(); | 72 | + ws.accept(); |
| 73 | + const __closeSse = () => { | ||
| 74 | + __sseClosed = true; | ||
| 75 | + try { | ||
| 76 | + clearInterval(keepAlive); | ||
| 77 | + } catch {} | ||
| 78 | + try { | ||
| 79 | + ws.close(1013, "SSE client not draining"); | ||
| 80 | + } catch {} | ||
| 81 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); | ||
| 82 | + }; | ||
| 48 | + const __forwardSse = (frame) => { | 83 | + const __forwardSse = (frame) => { |
| 49 | + if (__sseClosed) return __writeChain; | 84 | + if (__sseClosed) return __writeChain; |
| 50 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { | 85 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { |
| 51 | + __sseClosed = true; | 86 | + __closeSse(); |
| 52 | + try { | ||
| 53 | + clearInterval(keepAlive); | ||
| 54 | + } catch {} | ||
| 55 | + try { | ||
| 56 | + ws.close(1013, "SSE client not draining"); | ||
| 57 | + } catch {} | ||
| 58 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); | ||
| 59 | + return Promise.resolve(); | 87 | + return Promise.resolve(); |
| 60 | + } | 88 | + } |
| 61 | + __pendingBytes += frame.byteLength; | 89 | + __pendingBytes += frame.byteLength; |
| @@ -129,7 +157,7 @@ index 1edcf0c8..e65cd346 100644 · 67 unmodified lines | |||
| 129 | } | 157 | } |
| 130 | onClose().catch(console.error); | 158 | onClose().catch(console.error); |
| 131 | }); | 159 | }); |
| 132 | @@ -279,10 +307,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | 160 | @@ -279,10 +311,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { |
| 133 | id: null, | 161 | id: null, |
| 134 | jsonrpc: "2.0" | 162 | jsonrpc: "2.0" |
| 135 | }), { status: 400 }); | 163 | }), { status: 400 }); |
| @@ -140,6 +168,7 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines | |||
| 140 | + const { readable, writable } = new TransformStream(); | 168 | + const { readable, writable } = new TransformStream(); |
| 141 | + const writer = writable.getWriter(); | 169 | + const writer = writable.getWriter(); |
| 142 | + const encoder = new TextEncoder(); | 170 | + const encoder = new TextEncoder(); |
| 171 | + const __openedAt = Date.now(); | ||
| 143 | + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; | 172 | + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; |
| 144 | + let __pendingBytes = 0; | 173 | + let __pendingBytes = 0; |
| 145 | + let __writeChain = Promise.resolve(); | 174 | + let __writeChain = Promise.resolve(); |
| @@ -149,7 +178,7 @@ index 1edcf0c8..e65cd346 100644 · 3 unmodified lines | |||
| 149 | props: ctx.props, | 178 | props: ctx.props, |
| 150 | jurisdiction: options.jurisdiction | 179 | jurisdiction: options.jurisdiction |
| 151 | }); | 180 | }); |
| 152 | @@ -306,27 +339,51 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | 181 | @@ -306,27 +344,86 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { |
| 153 | if (!ws) { | 182 | if (!ws) { |
| 154 | await writer.close(); | 183 | await writer.close(); |
| 155 | return new Response("Failed to establish WS to DO", { status: 500 }); | 184 | return new Response("Failed to establish WS to DO", { status: 500 }); |
| @@ -158,17 +187,52 @@ index 1edcf0c8..e65cd346 100644 · 2 unmodified lines | |||
| 158 | - ws.addEventListener("message", (event) => { | 187 | - ws.addEventListener("message", (event) => { |
| 159 | + } | 188 | + } |
| 160 | + ws.accept(); | 189 | + ws.accept(); |
| 190 | + const __abortSse = () => { | ||
| 191 | + __sseClosed = true; | ||
| 192 | + try { | ||
| 193 | + clearInterval(keepAlive); | ||
| 194 | + } catch {} | ||
| 195 | + try { | ||
| 196 | + ws.close(1013, "SSE client not draining"); | ||
| 197 | + } catch {} | ||
| 198 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); | ||
| 199 | + }; | ||
| 200 | + const __closeSseGracefully = () => { | ||
| 201 | + __sseClosed = true; | ||
| 202 | + try { | ||
| 203 | + clearInterval(keepAlive); | ||
| 204 | + } catch {} | ||
| 205 | + try { | ||
| 206 | + const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); | ||
| 207 | + __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { | ||
| 208 | + writer.abort().catch(() => {}); | ||
| 209 | + }); | ||
| 210 | + } catch { | ||
| 211 | + __writeChain = writer.close().catch(() => { | ||
| 212 | + writer.abort().catch(() => {}); | ||
| 213 | + }); | ||
| 214 | + } | ||
| 215 | + try { | ||
| 216 | + ws.close(1000, "sse_max_age_rotation"); | ||
| 217 | + } catch {} | ||
| 218 | + return __writeChain; | ||
| 219 | + }; | ||
| 161 | + const __forwardSse = (frame) => { | 220 | + const __forwardSse = (frame) => { |
| 162 | + if (__sseClosed) return __writeChain; | 221 | + if (__sseClosed) return __writeChain; |
| 222 | + const ageMs = Date.now() - __openedAt; | ||
| 223 | + if (ageMs > MAX_SSE_AGE_MS) { | ||
| 224 | + console.log(JSON.stringify({ | ||
| 225 | + event: "sse_max_age_close", | ||
| 226 | + sessionId, | ||
| 227 | + variant: "streamable-get", | ||
| 228 | + ageMs, | ||
| 229 | + pendingBytes: __pendingBytes | ||
| 230 | + })); | ||
| 231 | + __closeSseGracefully(); | ||
| 232 | + return Promise.resolve(); | ||
| 233 | + } | ||
| 163 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { | 234 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { |
| 164 | + __sseClosed = true; | 235 | + __abortSse(); |
| 165 | + try { | ||
| 166 | + clearInterval(keepAlive); | ||
| 167 | + } catch {} | ||
| 168 | + try { | ||
| 169 | + ws.close(1013, "SSE client not draining"); | ||
| 170 | + } catch {} | ||
| 171 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); | ||
| 172 | + return Promise.resolve(); | 236 | + return Promise.resolve(); |
| 173 | + } | 237 | + } |
| 174 | + __pendingBytes += frame.byteLength; | 238 | + __pendingBytes += frame.byteLength; |
| @@ -216,7 +280,7 @@ index 1edcf0c8..e65cd346 100644 · 41 unmodified lines | |||
| 216 | return new Response(readable, { | 280 | return new Response(readable, { |
| 217 | headers: { | 281 | headers: { |
| 218 | "Cache-Control": "no-cache", | 282 | "Cache-Control": "no-cache", |
| 219 | @@ -389,10 +446,15 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { | 283 | @@ -389,10 +486,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { |
| 220 | const url = new URL(request.url); | 284 | const url = new URL(request.url); |
| 221 | if (request.method === "GET" && basePattern.test(url)) { | 285 | if (request.method === "GET" && basePattern.test(url)) { |
| 222 | const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); | 286 | const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); |
| @@ -227,6 +291,7 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines | |||
| 227 | + const { readable, writable } = new TransformStream(); | 291 | + const { readable, writable } = new TransformStream(); |
| 228 | + const writer = writable.getWriter(); | 292 | + const writer = writable.getWriter(); |
| 229 | + const encoder = new TextEncoder(); | 293 | + const encoder = new TextEncoder(); |
| 294 | + const __openedAt = Date.now(); | ||
| 230 | + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; | 295 | + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; |
| 231 | + let __pendingBytes = 0; | 296 | + let __pendingBytes = 0; |
| 232 | + let __writeChain = Promise.resolve(); | 297 | + let __writeChain = Promise.resolve(); |
| @@ -236,7 +301,7 @@ index 1edcf0c8..e65cd346 100644 · 3 unmodified lines | |||
| 236 | endpointUrl.pathname = encodeURI(`${basePath}/message`); | 301 | endpointUrl.pathname = encodeURI(`${basePath}/message`); |
| 237 | endpointUrl.searchParams.set("sessionId", sessionId); | 302 | endpointUrl.searchParams.set("sessionId", sessionId); |
| 238 | const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; | 303 | const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; |
| 239 | @@ -414,35 +476,59 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { | 304 | @@ -414,35 +517,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { |
| 240 | console.error("Failed to establish WebSocket connection"); | 305 | console.error("Failed to establish WebSocket connection"); |
| 241 | await writer.close(); | 306 | await writer.close(); |
| 242 | return new Response("Failed to establish WebSocket connection", { status: 500 }); | 307 | return new Response("Failed to establish WebSocket connection", { status: 500 }); |
| @@ -245,17 +310,52 @@ index 1edcf0c8..e65cd346 100644 · 2 unmodified lines | |||
| 245 | - ws.addEventListener("message", (event) => { | 310 | - ws.addEventListener("message", (event) => { |
| 246 | + } | 311 | + } |
| 247 | + ws.accept(); | 312 | + ws.accept(); |
| 313 | + const __abortSse = () => { | ||
| 314 | + __sseClosed = true; | ||
| 315 | + try { | ||
| 316 | + clearInterval(keepAlive); | ||
| 317 | + } catch {} | ||
| 318 | + try { | ||
| 319 | + ws.close(1013, "SSE client not draining"); | ||
| 320 | + } catch {} | ||
| 321 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); | ||
| 322 | + }; | ||
| 323 | + const __closeSseGracefully = () => { | ||
| 324 | + __sseClosed = true; | ||
| 325 | + try { | ||
| 326 | + clearInterval(keepAlive); | ||
| 327 | + } catch {} | ||
| 328 | + try { | ||
| 329 | + const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); | ||
| 330 | + __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { | ||
| 331 | + writer.abort().catch(() => {}); | ||
| 332 | + }); | ||
| 333 | + } catch { | ||
| 334 | + __writeChain = writer.close().catch(() => { | ||
| 335 | + writer.abort().catch(() => {}); | ||
| 336 | + }); | ||
| 337 | + } | ||
| 338 | + try { | ||
| 339 | + ws.close(1000, "sse_max_age_rotation"); | ||
| 340 | + } catch {} | ||
| 341 | + return __writeChain; | ||
| 342 | + }; | ||
| 248 | + const __forwardSse = (frame) => { | 343 | + const __forwardSse = (frame) => { |
| 249 | + if (__sseClosed) return __writeChain; | 344 | + if (__sseClosed) return __writeChain; |
| 345 | + const ageMs = Date.now() - __openedAt; | ||
| 346 | + if (ageMs > MAX_SSE_AGE_MS) { | ||
| 347 | + console.log(JSON.stringify({ | ||
| 348 | + event: "sse_max_age_close", | ||
| 349 | + sessionId, | ||
| 350 | + variant: "legacy-sse", | ||
| 351 | + ageMs, | ||
| 352 | + pendingBytes: __pendingBytes | ||
| 353 | + })); | ||
| 354 | + __closeSseGracefully(); | ||
| 355 | + return Promise.resolve(); | ||
| 356 | + } | ||
| 250 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { | 357 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { |
| 251 | + __sseClosed = true; | 358 | + __abortSse(); |
| 252 | + try { | ||
| 253 | + clearInterval(keepAlive); | ||
| 254 | + } catch {} | ||
| 255 | + try { | ||
| 256 | + ws.close(1013, "SSE client not draining"); | ||
| 257 | + } catch {} | ||
| 258 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); | ||
| 259 | + return Promise.resolve(); | 359 | + return Promise.resolve(); |
| 260 | + } | 360 | + } |
| 261 | + __pendingBytes += frame.byteLength; | 361 | + __pendingBytes += frame.byteLength; |
| @@ -314,10 +414,11 @@ index 1edcf0c8..e65cd346 100644 · 52 unmodified lines | |||
| 314 | console.error("Error closing SSE connection:", error); | 414 | console.error("Error closing SSE connection:", error); |
| 315 | } | 415 | } |
| 316 | } | 416 | } |
| 317 | @@ -1700,4 +1786,4 @@ McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; | 417 | @@ -1698,6 +1860,6 @@ var McpAgent = class McpAgent extends Agent { |
| 418 | }; | ||
| 419 | McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; | ||
| 318 | //#endregion | 420 | //#endregion |
| 319 | export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; | 421 | -export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; |
| 422 | +export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; | ||
| 320 | 423 | ||
| 321 | -//# sourceMappingURL=index.js.map | 424 | //# sourceMappingURL=index.js.map |
| 322 | \ No newline at end of file | ||
| 323 | +//# sourceMappingURL=index.js.map | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | #!/usr/bin/env bash | 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail | 2 | set -euo pipefail |
| 3 | APP=executor | 3 | APP=executor |
| 4 | REPO=RhysSullivan/executor | 4 | REPO=UsefulSoftwareCo/executor |
| 5 | 5 | ||
| 6 | MUTED='\033[0;2m' | 6 | MUTED='\033[0;2m' |
| 7 | RED='\033[0;31m' | 7 | RED='\033[0;31m' |
| @@ -8,7 +8,7 @@ const REFERENCE_DIR = join(import.meta.dirname, "../.reference"); · 7 unmodified lines | |||
| 8 | const repos = [ | 8 | const repos = [ |
| 9 | { name: "effect", url: "https://github.com/Effect-TS/effect.git" }, | 9 | { name: "effect", url: "https://github.com/Effect-TS/effect.git" }, |
| 10 | { name: "effect-atom", url: "https://github.com/tim-smart/effect-atom.git" }, | 10 | { name: "effect-atom", url: "https://github.com/tim-smart/effect-atom.git" }, |
| 11 | { name: "executor", url: "https://github.com/RhysSullivan/executor.git" }, | 11 | { name: "executor", url: "https://github.com/UsefulSoftwareCo/executor.git" }, |
| 12 | { | 12 | { |
| 13 | name: "tanstack-router", | 13 | name: "tanstack-router", |
| 14 | url: "https://github.com/TanStack/router.git", | 14 | url: "https://github.com/TanStack/router.git", |