Unified · Split
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 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 | 4 | "commit": false, |
| 5 | 5 | "fixed": [ |
| 6 | 6 | [ |
| @@ -7,7 +7,7 @@ body: · 6 unmodified lines | ||
| 7 | 7 | attributes: |
| 8 | 8 | value: | |
| 9 | 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 | 11 | guide — most crashes are fixed by updating to the latest version. |
| 12 | 12 | If you're still stuck, the details below help us pin it down fast. |
| 13 | 13 | - type: input |
| @@ -13,7 +13,7 @@ concurrency: · 12 unmodified lines | ||
| 13 | 13 | jobs: |
| 14 | 14 | format: |
| 15 | 15 | name: Format |
| 16 | − runs-on: ubuntu-latest | |
| 16 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 17 | 17 | steps: |
| 18 | 18 | - uses: actions/checkout@v4 |
| 19 | 19 | |
| @@ -29,13 +29,20 @@ jobs: · 9 unmodified lines | ||
| 29 | 29 | restore-keys: | |
| 30 | 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 | 39 | - run: bun install --frozen-lockfile |
| 33 | 40 | |
| 34 | 41 | - run: bun run format:check |
| 35 | 42 | |
| 36 | 43 | lint: |
| 37 | 44 | name: Lint |
| 38 | − runs-on: ubuntu-latest | |
| 45 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 39 | 46 | steps: |
| 40 | 47 | - uses: actions/checkout@v4 |
| 41 | 48 | |
| @@ -51,13 +58,20 @@ jobs: · 9 unmodified lines | ||
| 51 | 58 | restore-keys: | |
| 52 | 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 | 68 | - run: bun install --frozen-lockfile |
| 55 | 69 | |
| 56 | 70 | - run: bun run lint |
| 57 | 71 | |
| 58 | 72 | typecheck: |
| 59 | 73 | name: Typecheck |
| 60 | − runs-on: ubuntu-latest | |
| 74 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 61 | 75 | steps: |
| 62 | 76 | - uses: actions/checkout@v4 |
| 63 | 77 | |
| @@ -73,13 +87,20 @@ jobs: · 9 unmodified lines | ||
| 73 | 87 | restore-keys: | |
| 74 | 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 | 97 | - run: bun install --frozen-lockfile |
| 77 | 98 | |
| 78 | 99 | - run: bun run typecheck |
| 79 | 100 | |
| 80 | 101 | test: |
| 81 | 102 | name: Test |
| 82 | − runs-on: ubuntu-latest | |
| 103 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 83 | 104 | env: |
| 84 | 105 | TURBO_TEST_CONCURRENCY: 3 |
| 85 | 106 | steps: |
| @@ -130,7 +151,7 @@ jobs: · 44 unmodified lines | ||
| 130 | 151 | - { target: cloud, shard: 7/8, shard-name: 7of8 } |
| 131 | 152 | - { target: cloud, shard: 8/8, shard-name: 8of8 } |
| 132 | 153 | - target: selfhost |
| 133 | − runs-on: ubuntu-latest | |
| 154 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 134 | 155 | timeout-minutes: 30 |
| 135 | 156 | steps: |
| 136 | 157 | - uses: actions/checkout@v4 |
| @@ -191,7 +212,7 @@ jobs: · 54 unmodified lines | ||
| 191 | 212 | # Skipped on pull_request: the local scenario boots a real `executor web` |
| 192 | 213 | # plus a browser and is currently flaky on PRs. Still runs on push to main. |
| 193 | 214 | if: github.event_name != 'pull_request' |
| 194 | − runs-on: ubuntu-latest | |
| 215 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 195 | 216 | timeout-minutes: 20 |
| 196 | 217 | steps: |
| 197 | 218 | - uses: actions/checkout@v4 |
| @@ -244,7 +265,7 @@ jobs: · 46 unmodified lines | ||
| 244 | 265 | |
| 245 | 266 | desktop-smoke: |
| 246 | 267 | name: Desktop smoke build |
| 247 | − runs-on: ubuntu-latest | |
| 268 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 248 | 269 | steps: |
| 249 | 270 | - uses: actions/checkout@v4 |
| 250 | 271 | |
| @@ -260,6 +281,13 @@ jobs: · 9 unmodified lines | ||
| 260 | 281 | restore-keys: | |
| 261 | 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 | 291 | - run: bun install --frozen-lockfile |
| 264 | 292 | |
| 265 | 293 | - name: Build web app |
| @@ -277,19 +305,17 @@ jobs: · 11 unmodified lines | ||
| 277 | 305 | |
| 278 | 306 | selfhost-docker-smoke: |
| 279 | 307 | name: Self-host Docker image |
| 280 | − runs-on: ubuntu-latest | |
| 308 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 281 | 309 | steps: |
| 282 | 310 | - uses: actions/checkout@v4 |
| 283 | 311 | |
| 284 | − - name: Set up Docker Buildx | |
| 285 | − uses: docker/setup-buildx-action@v3 | |
| 312 | + - name: Setup Blacksmith Builder | |
| 313 | + uses: useblacksmith/setup-docker-builder@v1 | |
| 286 | 314 | |
| 287 | 315 | - name: Build self-host image |
| 288 | − uses: docker/build-push-action@v6 | |
| 316 | + uses: useblacksmith/build-push-action@v2 | |
| 289 | 317 | with: |
| 290 | 318 | context: . |
| 291 | 319 | file: apps/host-selfhost/Dockerfile |
| 292 | 320 | push: false |
| 293 | 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 | 64 | publish: |
| 65 | 65 | name: Publish |
| 66 | 66 | needs: build-preview-binary |
| 67 | − runs-on: ubuntu-latest | |
| 67 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 68 | 68 | permissions: |
| 69 | 69 | contents: read |
| 70 | 70 | pull-requests: write |
| @@ -77,6 +77,13 @@ jobs: · 6 unmodified lines | ||
| 77 | 77 | with: |
| 78 | 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 | 87 | - run: bun install --frozen-lockfile |
| 81 | 88 | |
| 82 | 89 | - name: Build executor preview wrapper |
| @@ -19,7 +19,7 @@ permissions: · 18 unmodified lines | ||
| 19 | 19 | jobs: |
| 20 | 20 | sweep: |
| 21 | 21 | name: Destroy previews for closed PRs |
| 22 | − runs-on: ubuntu-latest | |
| 22 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 23 | 23 | steps: |
| 24 | 24 | - uses: actions/checkout@v4 |
| 25 | 25 | |
| @@ -27,7 +27,7 @@ jobs: · 26 unmodified lines | ||
| 27 | 27 | deploy: |
| 28 | 28 | name: Deploy preview |
| 29 | 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 | 31 | steps: |
| 32 | 32 | - uses: actions/checkout@v4 |
| 33 | 33 | |
| @@ -35,6 +35,13 @@ jobs: · 1 unmodified line | ||
| 35 | 35 | with: |
| 36 | 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 | 45 | - run: bun install --frozen-lockfile |
| 39 | 46 | |
| 40 | 47 | - name: Deploy |
| @@ -82,7 +89,7 @@ jobs: · 41 unmodified lines | ||
| 82 | 89 | teardown: |
| 83 | 90 | name: Tear down preview |
| 84 | 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 | 93 | steps: |
| 87 | 94 | - uses: actions/checkout@v4 |
| 88 | 95 | |
| @@ -177,7 +177,7 @@ jobs: · 176 unmodified lines | ||
| 177 | 177 | |
| 178 | 178 | release: |
| 179 | 179 | needs: build |
| 180 | − runs-on: ubuntu-latest | |
| 180 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 181 | 181 | permissions: |
| 182 | 182 | contents: write |
| 183 | 183 | |
| @@ -21,7 +21,7 @@ concurrency: · 20 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | jobs: |
| 23 | 23 | publish: |
| 24 | − runs-on: ubuntu-latest | |
| 24 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 25 | 25 | permissions: |
| 26 | 26 | actions: write |
| 27 | 27 | contents: write |
| @@ -18,10 +18,12 @@ concurrency: · 17 unmodified lines | ||
| 18 | 18 | |
| 19 | 19 | jobs: |
| 20 | 20 | publish: |
| 21 | − runs-on: ubuntu-latest | |
| 21 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 22 | 22 | permissions: |
| 23 | 23 | contents: read |
| 24 | 24 | packages: write |
| 25 | + env: | |
| 26 | + HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} | |
| 25 | 27 | |
| 26 | 28 | steps: |
| 27 | 29 | - name: Checkout |
| @@ -57,6 +59,7 @@ jobs: · 29 unmodified lines | ||
| 57 | 59 | version="${RELEASE_TAG#v}" |
| 58 | 60 | owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')" |
| 59 | 61 | image="ghcr.io/${owner}/executor-selfhost" |
| 62 | + legacy_image="ghcr.io/rhyssullivan/executor-selfhost" | |
| 60 | 63 | revision="$(git rev-parse HEAD)" |
| 61 | 64 | |
| 62 | 65 | if [[ "$version" == *-* ]]; then |
| @@ -74,6 +77,11 @@ jobs: · 11 unmodified lines | ||
| 74 | 77 | echo "${image}:${version}" |
| 75 | 78 | echo "${image}:${channel}" |
| 76 | 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 | 85 | echo "labels<<LABELS" |
| 78 | 86 | echo "org.opencontainers.image.title=Executor self-host" |
| 79 | 87 | echo "org.opencontainers.image.description=Single-container self-hosted Executor" |
| @@ -86,8 +94,8 @@ jobs: · 6 unmodified lines | ||
| 86 | 94 | - name: Set up QEMU |
| 87 | 95 | uses: docker/setup-qemu-action@v3 |
| 88 | 96 | |
| 89 | − - name: Set up Docker Buildx | |
| 90 | − uses: docker/setup-buildx-action@v3 | |
| 97 | + - name: Setup Blacksmith Builder | |
| 98 | + uses: useblacksmith/setup-docker-builder@v1 | |
| 91 | 99 | |
| 92 | 100 | - name: Log in to GHCR |
| 93 | 101 | uses: docker/login-action@v3 |
| @@ -97,7 +105,8 @@ jobs: · 3 unmodified lines | ||
| 97 | 105 | password: ${{ secrets.GITHUB_TOKEN }} |
| 98 | 106 | |
| 99 | 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 | 110 | with: |
| 102 | 111 | context: . |
| 103 | 112 | file: apps/host-selfhost/Dockerfile |
| @@ -105,3 +114,32 @@ jobs: · 1 unmodified line | ||
| 105 | 114 | push: true |
| 106 | 115 | tags: ${{ steps.image.outputs.tags }} |
| 107 | 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 | 16 | jobs: |
| 17 | 17 | release: |
| 18 | − runs-on: ubuntu-latest | |
| 18 | + runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 19 | 19 | permissions: |
| 20 | 20 | actions: write |
| 21 | 21 | contents: write |
| @@ -104,7 +104,7 @@ Identical to beta except skip `release:beta:start`/`stop`. Changesets produce a · 103 unmodified lines | ||
| 104 | 104 | - **Never commit until the owner explicitly says so.** Set everything up in the working tree, run `git status`, and stop. |
| 105 | 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 | 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 | 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 | 109 | - **Don't estimate time** — code is cheap to write. Focus on what to do, not how long it takes. |
| 110 | 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 | 225 | ## Community |
| 226 | 226 | |
| 227 | 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 | 230 | ## License |
| 231 | 231 | |
| @@ -24,7 +24,7 @@ the self-host Docker image. · 23 unmodified lines | ||
| 24 | 24 | - creates or updates the GitHub release with build artifacts |
| 25 | 25 | - dispatches `.github/workflows/publish-desktop.yml` |
| 26 | 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 | 28 | for `linux/amd64` and `linux/arm64`: |
| 29 | 29 | - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest` |
| 30 | 30 | - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta` |
| @@ -0,0 +1 @@ | ||
| 1 | +ALTER TABLE "oauth_client" ADD COLUMN "origin_issuer" text; | |
| @@ -0,0 +1,1278 @@ | ||
| 1 | +{ | |
| 2 | + "id": "a0208f9c-622b-4c94-bd09-33bcf37c2462", | |
| 3 | + "prevId": "2f557ed1-5afc-431b-8623-2a2162793f43", | |
| 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 | + "tools_synced_at": { | |
| 249 | + "name": "tools_synced_at", | |
| 250 | + "type": "bigint", | |
| 251 | + "primaryKey": false, | |
| 252 | + "notNull": false | |
| 253 | + }, | |
| 254 | + "oauth_client": { | |
| 255 | + "name": "oauth_client", | |
| 256 | + "type": "text", | |
| 257 | + "primaryKey": false, | |
| 258 | + "notNull": false | |
| 259 | + }, | |
| 260 | + "oauth_client_owner": { | |
| 261 | + "name": "oauth_client_owner", | |
| 262 | + "type": "text", | |
| 263 | + "primaryKey": false, | |
| 264 | + "notNull": false | |
| 265 | + }, | |
| 266 | + "refresh_item_id": { | |
| 267 | + "name": "refresh_item_id", | |
| 268 | + "type": "text", | |
| 269 | + "primaryKey": false, | |
| 270 | + "notNull": false | |
| 271 | + }, | |
| 272 | + "expires_at": { | |
| 273 | + "name": "expires_at", | |
| 274 | + "type": "bigint", | |
| 275 | + "primaryKey": false, | |
| 276 | + "notNull": false | |
| 277 | + }, | |
| 278 | + "oauth_scope": { | |
| 279 | + "name": "oauth_scope", | |
| 280 | + "type": "text", | |
| 281 | + "primaryKey": false, | |
| 282 | + "notNull": false | |
| 283 | + }, | |
| 284 | + "oauth_token_url": { | |
| 285 | + "name": "oauth_token_url", | |
| 286 | + "type": "text", | |
| 287 | + "primaryKey": false, | |
| 288 | + "notNull": false | |
| 289 | + }, | |
| 290 | + "provider_state": { | |
| 291 | + "name": "provider_state", | |
| 292 | + "type": "json", | |
| 293 | + "primaryKey": false, | |
| 294 | + "notNull": false | |
| 295 | + }, | |
| 296 | + "created_at": { | |
| 297 | + "name": "created_at", | |
| 298 | + "type": "timestamp", | |
| 299 | + "primaryKey": false, | |
| 300 | + "notNull": true | |
| 301 | + }, | |
| 302 | + "updated_at": { | |
| 303 | + "name": "updated_at", | |
| 304 | + "type": "timestamp", | |
| 305 | + "primaryKey": false, | |
| 306 | + "notNull": true | |
| 307 | + }, | |
| 308 | + "row_id": { | |
| 309 | + "name": "row_id", | |
| 310 | + "type": "varchar(255)", | |
| 311 | + "primaryKey": true, | |
| 312 | + "notNull": true | |
| 313 | + }, | |
| 314 | + "tenant": { | |
| 315 | + "name": "tenant", | |
| 316 | + "type": "varchar(255)", | |
| 317 | + "primaryKey": false, | |
| 318 | + "notNull": true | |
| 319 | + }, | |
| 320 | + "owner": { | |
| 321 | + "name": "owner", | |
| 322 | + "type": "varchar(255)", | |
| 323 | + "primaryKey": false, | |
| 324 | + "notNull": true | |
| 325 | + }, | |
| 326 | + "subject": { | |
| 327 | + "name": "subject", | |
| 328 | + "type": "varchar(255)", | |
| 329 | + "primaryKey": false, | |
| 330 | + "notNull": true | |
| 331 | + } | |
| 332 | + }, | |
| 333 | + "indexes": { | |
| 334 | + "connection_uidx": { | |
| 335 | + "name": "connection_uidx", | |
| 336 | + "columns": [ | |
| 337 | + { | |
| 338 | + "expression": "tenant", | |
| 339 | + "isExpression": false, | |
| 340 | + "asc": true, | |
| 341 | + "nulls": "last" | |
| 342 | + }, | |
| 343 | + { | |
| 344 | + "expression": "owner", | |
| 345 | + "isExpression": false, | |
| 346 | + "asc": true, | |
| 347 | + "nulls": "last" | |
| 348 | + }, | |
| 349 | + { | |
| 350 | + "expression": "subject", | |
| 351 | + "isExpression": false, | |
| 352 | + "asc": true, | |
| 353 | + "nulls": "last" | |
| 354 | + }, | |
| 355 | + { | |
| 356 | + "expression": "integration", | |
| 357 | + "isExpression": false, | |
| 358 | + "asc": true, | |
| 359 | + "nulls": "last" | |
| 360 | + }, | |
| 361 | + { | |
| 362 | + "expression": "name", | |
| 363 | + "isExpression": false, | |
| 364 | + "asc": true, | |
| 365 | + "nulls": "last" | |
| 366 | + } | |
| 367 | + ], | |
| 368 | + "isUnique": true, | |
| 369 | + "concurrently": false, | |
| 370 | + "method": "btree", | |
| 371 | + "with": {} | |
| 372 | + } | |
| 373 | + }, | |
| 374 | + "foreignKeys": {}, | |
| 375 | + "compositePrimaryKeys": {}, | |
| 376 | + "uniqueConstraints": {}, | |
| 377 | + "policies": {}, | |
| 378 | + "checkConstraints": {}, | |
| 379 | + "isRLSEnabled": false | |
| 380 | + }, | |
| 381 | + "public.definition": { | |
| 382 | + "name": "definition", | |
| 383 | + "schema": "", | |
| 384 | + "columns": { | |
| 385 | + "integration": { | |
| 386 | + "name": "integration", | |
| 387 | + "type": "varchar(255)", | |
| 388 | + "primaryKey": false, | |
| 389 | + "notNull": true | |
| 390 | + }, | |
| 391 | + "connection": { | |
| 392 | + "name": "connection", | |
| 393 | + "type": "varchar(255)", | |
| 394 | + "primaryKey": false, | |
| 395 | + "notNull": true | |
| 396 | + }, | |
| 397 | + "plugin_id": { | |
| 398 | + "name": "plugin_id", | |
| 399 | + "type": "text", | |
| 400 | + "primaryKey": false, | |
| 401 | + "notNull": true | |
| 402 | + }, | |
| 403 | + "name": { | |
| 404 | + "name": "name", | |
| 405 | + "type": "text", | |
| 406 | + "primaryKey": false, | |
| 407 | + "notNull": true | |
| 408 | + }, | |
| 409 | + "schema": { | |
| 410 | + "name": "schema", | |
| 411 | + "type": "json", | |
| 412 | + "primaryKey": false, | |
| 413 | + "notNull": true | |
| 414 | + }, | |
| 415 | + "created_at": { | |
| 416 | + "name": "created_at", | |
| 417 | + "type": "timestamp", | |
| 418 | + "primaryKey": false, | |
| 419 | + "notNull": true | |
| 420 | + }, | |
| 421 | + "row_id": { | |
| 422 | + "name": "row_id", | |
| 423 | + "type": "varchar(255)", | |
| 424 | + "primaryKey": true, | |
| 425 | + "notNull": true | |
| 426 | + }, | |
| 427 | + "tenant": { | |
| 428 | + "name": "tenant", | |
| 429 | + "type": "varchar(255)", | |
| 430 | + "primaryKey": false, | |
| 431 | + "notNull": true | |
| 432 | + }, | |
| 433 | + "owner": { | |
| 434 | + "name": "owner", | |
| 435 | + "type": "varchar(255)", | |
| 436 | + "primaryKey": false, | |
| 437 | + "notNull": true | |
| 438 | + }, | |
| 439 | + "subject": { | |
| 440 | + "name": "subject", | |
| 441 | + "type": "varchar(255)", | |
| 442 | + "primaryKey": false, | |
| 443 | + "notNull": true | |
| 444 | + } | |
| 445 | + }, | |
| 446 | + "indexes": { | |
| 447 | + "definition_uidx": { | |
| 448 | + "name": "definition_uidx", | |
| 449 | + "columns": [ | |
| 450 | + { | |
| 451 | + "expression": "tenant", | |
| 452 | + "isExpression": false, | |
| 453 | + "asc": true, | |
| 454 | + "nulls": "last" | |
| 455 | + }, | |
| 456 | + { | |
| 457 | + "expression": "owner", | |
| 458 | + "isExpression": false, | |
| 459 | + "asc": true, | |
| 460 | + "nulls": "last" | |
| 461 | + }, | |
| 462 | + { | |
| 463 | + "expression": "subject", | |
| 464 | + "isExpression": false, | |
| 465 | + "asc": true, | |
| 466 | + "nulls": "last" | |
| 467 | + }, | |
| 468 | + { | |
| 469 | + "expression": "integration", | |
| 470 | + "isExpression": false, | |
| 471 | + "asc": true, | |
| 472 | + "nulls": "last" | |
| 473 | + }, | |
| 474 | + { | |
| 475 | + "expression": "connection", | |
| 476 | + "isExpression": false, | |
| 477 | + "asc": true, | |
| 478 | + "nulls": "last" | |
| 479 | + }, | |
| 480 | + { | |
| 481 | + "expression": "name", | |
| 482 | + "isExpression": false, | |
| 483 | + "asc": true, | |
| 484 | + "nulls": "last" | |
| 485 | + } | |
| 486 | + ], | |
| 487 | + "isUnique": true, | |
| 488 | + "concurrently": false, | |
| 489 | + "method": "btree", | |
| 490 | + "with": {} | |
| 491 | + } | |
| 492 | + }, | |
| 493 | + "foreignKeys": {}, | |
| 494 | + "compositePrimaryKeys": {}, | |
| 495 | + "uniqueConstraints": {}, | |
| 496 | + "policies": {}, | |
| 497 | + "checkConstraints": {}, | |
| 498 | + "isRLSEnabled": false | |
| 499 | + }, | |
| 500 | + "public.integration": { | |
| 501 | + "name": "integration", | |
| 502 | + "schema": "", | |
| 503 | + "columns": { | |
| 504 | + "slug": { | |
| 505 | + "name": "slug", | |
| 506 | + "type": "varchar(255)", | |
| 507 | + "primaryKey": false, | |
| 508 | + "notNull": true | |
| 509 | + }, | |
| 510 | + "plugin_id": { | |
| 511 | + "name": "plugin_id", | |
| 512 | + "type": "text", | |
| 513 | + "primaryKey": false, | |
| 514 | + "notNull": true | |
| 515 | + }, | |
| 516 | + "name": { | |
| 517 | + "name": "name", | |
| 518 | + "type": "text", | |
| 519 | + "primaryKey": false, | |
| 520 | + "notNull": false | |
| 521 | + }, | |
| 522 | + "description": { | |
| 523 | + "name": "description", | |
| 524 | + "type": "text", | |
| 525 | + "primaryKey": false, | |
| 526 | + "notNull": false | |
| 527 | + }, | |
| 528 | + "config_revised_at": { | |
| 529 | + "name": "config_revised_at", | |
| 530 | + "type": "bigint", | |
| 531 | + "primaryKey": false, | |
| 532 | + "notNull": false | |
| 533 | + }, | |
| 534 | + "config": { | |
| 535 | + "name": "config", | |
| 536 | + "type": "json", | |
| 537 | + "primaryKey": false, | |
| 538 | + "notNull": false | |
| 539 | + }, | |
| 540 | + "can_remove": { | |
| 541 | + "name": "can_remove", | |
| 542 | + "type": "boolean", | |
| 543 | + "primaryKey": false, | |
| 544 | + "notNull": true, | |
| 545 | + "default": true | |
| 546 | + }, | |
| 547 | + "can_refresh": { | |
| 548 | + "name": "can_refresh", | |
| 549 | + "type": "boolean", | |
| 550 | + "primaryKey": false, | |
| 551 | + "notNull": true, | |
| 552 | + "default": false | |
| 553 | + }, | |
| 554 | + "created_at": { | |
| 555 | + "name": "created_at", | |
| 556 | + "type": "timestamp", | |
| 557 | + "primaryKey": false, | |
| 558 | + "notNull": true | |
| 559 | + }, | |
| 560 | + "updated_at": { | |
| 561 | + "name": "updated_at", | |
| 562 | + "type": "timestamp", | |
| 563 | + "primaryKey": false, | |
| 564 | + "notNull": true | |
| 565 | + }, | |
| 566 | + "row_id": { | |
| 567 | + "name": "row_id", | |
| 568 | + "type": "varchar(255)", | |
| 569 | + "primaryKey": true, | |
| 570 | + "notNull": true | |
| 571 | + }, | |
| 572 | + "tenant": { | |
| 573 | + "name": "tenant", | |
| 574 | + "type": "varchar(255)", | |
| 575 | + "primaryKey": false, | |
| 576 | + "notNull": true | |
| 577 | + } | |
| 578 | + }, | |
| 579 | + "indexes": { | |
| 580 | + "integration_uidx": { | |
| 581 | + "name": "integration_uidx", | |
| 582 | + "columns": [ | |
| 583 | + { | |
| 584 | + "expression": "tenant", | |
| 585 | + "isExpression": false, | |
| 586 | + "asc": true, | |
| 587 | + "nulls": "last" | |
| 588 | + }, | |
| 589 | + { | |
| 590 | + "expression": "slug", | |
| 591 | + "isExpression": false, | |
| 592 | + "asc": true, | |
| 593 | + "nulls": "last" | |
| 594 | + } | |
| 595 | + ], | |
| 596 | + "isUnique": true, | |
| 597 | + "concurrently": false, | |
| 598 | + "method": "btree", | |
| 599 | + "with": {} | |
| 600 | + } | |
| 601 | + }, | |
| 602 | + "foreignKeys": {}, | |
| 603 | + "compositePrimaryKeys": {}, | |
| 604 | + "uniqueConstraints": {}, | |
| 605 | + "policies": {}, | |
| 606 | + "checkConstraints": {}, | |
| 607 | + "isRLSEnabled": false | |
| 608 | + }, | |
| 609 | + "public.oauth_client": { | |
| 610 | + "name": "oauth_client", | |
| 611 | + "schema": "", | |
| 612 | + "columns": { | |
| 613 | + "slug": { | |
| 614 | + "name": "slug", | |
| 615 | + "type": "varchar(255)", | |
| 616 | + "primaryKey": false, | |
| 617 | + "notNull": true | |
| 618 | + }, | |
| 619 | + "authorization_url": { | |
| 620 | + "name": "authorization_url", | |
| 621 | + "type": "text", | |
| 622 | + "primaryKey": false, | |
| 623 | + "notNull": true | |
| 624 | + }, | |
| 625 | + "token_url": { | |
| 626 | + "name": "token_url", | |
| 627 | + "type": "text", | |
| 628 | + "primaryKey": false, | |
| 629 | + "notNull": true | |
| 630 | + }, | |
| 631 | + "grant": { | |
| 632 | + "name": "grant", | |
| 633 | + "type": "text", | |
| 634 | + "primaryKey": false, | |
| 635 | + "notNull": true | |
| 636 | + }, | |
| 637 | + "client_id": { | |
| 638 | + "name": "client_id", | |
| 639 | + "type": "text", | |
| 640 | + "primaryKey": false, | |
| 641 | + "notNull": true | |
| 642 | + }, | |
| 643 | + "client_secret_item_id": { | |
| 644 | + "name": "client_secret_item_id", | |
| 645 | + "type": "text", | |
| 646 | + "primaryKey": false, | |
| 647 | + "notNull": false | |
| 648 | + }, | |
| 649 | + "resource": { | |
| 650 | + "name": "resource", | |
| 651 | + "type": "text", | |
| 652 | + "primaryKey": false, | |
| 653 | + "notNull": false | |
| 654 | + }, | |
| 655 | + "origin_kind": { | |
| 656 | + "name": "origin_kind", | |
| 657 | + "type": "text", | |
| 658 | + "primaryKey": false, | |
| 659 | + "notNull": false | |
| 660 | + }, | |
| 661 | + "origin_integration": { | |
| 662 | + "name": "origin_integration", | |
| 663 | + "type": "text", | |
| 664 | + "primaryKey": false, | |
| 665 | + "notNull": false | |
| 666 | + }, | |
| 667 | + "origin_issuer": { | |
| 668 | + "name": "origin_issuer", | |
| 669 | + "type": "text", | |
| 670 | + "primaryKey": false, | |
| 671 | + "notNull": false | |
| 672 | + }, | |
| 673 | + "created_at": { | |
| 674 | + "name": "created_at", | |
| 675 | + "type": "timestamp", | |
| 676 | + "primaryKey": false, | |
| 677 | + "notNull": true | |
| 678 | + }, | |
| 679 | + "row_id": { | |
| 680 | + "name": "row_id", | |
| 681 | + "type": "varchar(255)", | |
| 682 | + "primaryKey": true, | |
| 683 | + "notNull": true | |
| 684 | + }, | |
| 685 | + "tenant": { | |
| 686 | + "name": "tenant", | |
| 687 | + "type": "varchar(255)", | |
| 688 | + "primaryKey": false, | |
| 689 | + "notNull": true | |
| 690 | + }, | |
| 691 | + "owner": { | |
| 692 | + "name": "owner", | |
| 693 | + "type": "varchar(255)", | |
| 694 | + "primaryKey": false, | |
| 695 | + "notNull": true | |
| 696 | + }, | |
| 697 | + "subject": { | |
| 698 | + "name": "subject", | |
| 699 | + "type": "varchar(255)", | |
| 700 | + "primaryKey": false, | |
| 701 | + "notNull": true | |
| 702 | + } | |
| 703 | + }, | |
| 704 | + "indexes": { | |
| 705 | + "oauth_client_uidx": { | |
| 706 | + "name": "oauth_client_uidx", | |
| 707 | + "columns": [ | |
| 708 | + { | |
| 709 | + "expression": "tenant", | |
| 710 | + "isExpression": false, | |
| 711 | + "asc": true, | |
| 712 | + "nulls": "last" | |
| 713 | + }, | |
| 714 | + { | |
| 715 | + "expression": "owner", | |
| 716 | + "isExpression": false, | |
| 717 | + "asc": true, | |
| 718 | + "nulls": "last" | |
| 719 | + }, | |
| 720 | + { | |
| 721 | + "expression": "subject", | |
| 722 | + "isExpression": false, | |
| 723 | + "asc": true, | |
| 724 | + "nulls": "last" | |
| 725 | + }, | |
| 726 | + { | |
| 727 | + "expression": "slug", | |
| 728 | + "isExpression": false, | |
| 729 | + "asc": true, | |
| 730 | + "nulls": "last" | |
| 731 | + } | |
| 732 | + ], | |
| 733 | + "isUnique": true, | |
| 734 | + "concurrently": false, | |
| 735 | + "method": "btree", | |
| 736 | + "with": {} | |
| 737 | + } | |
| 738 | + }, | |
| 739 | + "foreignKeys": {}, | |
| 740 | + "compositePrimaryKeys": {}, | |
| 741 | + "uniqueConstraints": {}, | |
| 742 | + "policies": {}, | |
| 743 | + "checkConstraints": {}, | |
| 744 | + "isRLSEnabled": false | |
| 745 | + }, | |
| 746 | + "public.oauth_session": { | |
| 747 | + "name": "oauth_session", | |
| 748 | + "schema": "", | |
| 749 | + "columns": { | |
| 750 | + "state": { | |
| 751 | + "name": "state", | |
| 752 | + "type": "varchar(255)", | |
| 753 | + "primaryKey": false, | |
| 754 | + "notNull": true | |
| 755 | + }, | |
| 756 | + "client_slug": { | |
| 757 | + "name": "client_slug", | |
| 758 | + "type": "text", | |
| 759 | + "primaryKey": false, | |
| 760 | + "notNull": true | |
| 761 | + }, | |
| 762 | + "integration": { | |
| 763 | + "name": "integration", | |
| 764 | + "type": "text", | |
| 765 | + "primaryKey": false, | |
| 766 | + "notNull": true | |
| 767 | + }, | |
| 768 | + "name": { | |
| 769 | + "name": "name", | |
| 770 | + "type": "text", | |
| 771 | + "primaryKey": false, | |
| 772 | + "notNull": true | |
| 773 | + }, | |
| 774 | + "template": { | |
| 775 | + "name": "template", | |
| 776 | + "type": "text", | |
| 777 | + "primaryKey": false, | |
| 778 | + "notNull": true | |
| 779 | + }, | |
| 780 | + "redirect_url": { | |
| 781 | + "name": "redirect_url", | |
| 782 | + "type": "text", | |
| 783 | + "primaryKey": false, | |
| 784 | + "notNull": true | |
| 785 | + }, | |
| 786 | + "pkce_verifier": { | |
| 787 | + "name": "pkce_verifier", | |
| 788 | + "type": "text", | |
| 789 | + "primaryKey": false, | |
| 790 | + "notNull": false | |
| 791 | + }, | |
| 792 | + "identity_label": { | |
| 793 | + "name": "identity_label", | |
| 794 | + "type": "text", | |
| 795 | + "primaryKey": false, | |
| 796 | + "notNull": false | |
| 797 | + }, | |
| 798 | + "payload": { | |
| 799 | + "name": "payload", | |
| 800 | + "type": "json", | |
| 801 | + "primaryKey": false, | |
| 802 | + "notNull": true | |
| 803 | + }, | |
| 804 | + "expires_at": { | |
| 805 | + "name": "expires_at", | |
| 806 | + "type": "bigint", | |
| 807 | + "primaryKey": false, | |
| 808 | + "notNull": true | |
| 809 | + }, | |
| 810 | + "created_at": { | |
| 811 | + "name": "created_at", | |
| 812 | + "type": "timestamp", | |
| 813 | + "primaryKey": false, | |
| 814 | + "notNull": true | |
| 815 | + }, | |
| 816 | + "row_id": { | |
| 817 | + "name": "row_id", | |
| 818 | + "type": "varchar(255)", | |
| 819 | + "primaryKey": true, | |
| 820 | + "notNull": true | |
| 821 | + }, | |
| 822 | + "tenant": { | |
| 823 | + "name": "tenant", | |
| 824 | + "type": "varchar(255)", | |
| 825 | + "primaryKey": false, | |
| 826 | + "notNull": true | |
| 827 | + }, | |
| 828 | + "owner": { | |
| 829 | + "name": "owner", | |
| 830 | + "type": "varchar(255)", | |
| 831 | + "primaryKey": false, | |
| 832 | + "notNull": true | |
| 833 | + }, | |
| 834 | + "subject": { | |
| 835 | + "name": "subject", | |
| 836 | + "type": "varchar(255)", | |
| 837 | + "primaryKey": false, | |
| 838 | + "notNull": true | |
| 839 | + } | |
| 840 | + }, | |
| 841 | + "indexes": { | |
| 842 | + "oauth_session_uidx": { | |
| 843 | + "name": "oauth_session_uidx", | |
| 844 | + "columns": [ | |
| 845 | + { | |
| 846 | + "expression": "tenant", | |
| 847 | + "isExpression": false, | |
| 848 | + "asc": true, | |
| 849 | + "nulls": "last" | |
| 850 | + }, | |
| 851 | + { | |
| 852 | + "expression": "state", | |
| 853 | + "isExpression": false, | |
| 854 | + "asc": true, | |
| 855 | + "nulls": "last" | |
| 856 | + } | |
| 857 | + ], | |
| 858 | + "isUnique": true, | |
| 859 | + "concurrently": false, | |
| 860 | + "method": "btree", | |
| 861 | + "with": {} | |
| 862 | + } | |
| 863 | + }, | |
| 864 | + "foreignKeys": {}, | |
| 865 | + "compositePrimaryKeys": {}, | |
| 866 | + "uniqueConstraints": {}, | |
| 867 | + "policies": {}, | |
| 868 | + "checkConstraints": {}, | |
| 869 | + "isRLSEnabled": false | |
| 870 | + }, | |
| 871 | + "public.plugin_storage": { | |
| 872 | + "name": "plugin_storage", | |
| 873 | + "schema": "", | |
| 874 | + "columns": { | |
| 875 | + "plugin_id": { | |
| 876 | + "name": "plugin_id", | |
| 877 | + "type": "varchar(255)", | |
| 878 | + "primaryKey": false, | |
| 879 | + "notNull": true | |
| 880 | + }, | |
| 881 | + "collection": { | |
| 882 | + "name": "collection", | |
| 883 | + "type": "varchar(255)", | |
| 884 | + "primaryKey": false, | |
| 885 | + "notNull": true | |
| 886 | + }, | |
| 887 | + "key": { | |
| 888 | + "name": "key", | |
| 889 | + "type": "varchar(255)", | |
| 890 | + "primaryKey": false, | |
| 891 | + "notNull": true | |
| 892 | + }, | |
| 893 | + "data": { | |
| 894 | + "name": "data", | |
| 895 | + "type": "json", | |
| 896 | + "primaryKey": false, | |
| 897 | + "notNull": true | |
| 898 | + }, | |
| 899 | + "created_at": { | |
| 900 | + "name": "created_at", | |
| 901 | + "type": "timestamp", | |
| 902 | + "primaryKey": false, | |
| 903 | + "notNull": true | |
| 904 | + }, | |
| 905 | + "updated_at": { | |
| 906 | + "name": "updated_at", | |
| 907 | + "type": "timestamp", | |
| 908 | + "primaryKey": false, | |
| 909 | + "notNull": true | |
| 910 | + }, | |
| 911 | + "row_id": { | |
| 912 | + "name": "row_id", | |
| 913 | + "type": "varchar(255)", | |
| 914 | + "primaryKey": true, | |
| 915 | + "notNull": true | |
| 916 | + }, | |
| 917 | + "tenant": { | |
| 918 | + "name": "tenant", | |
| 919 | + "type": "varchar(255)", | |
| 920 | + "primaryKey": false, | |
| 921 | + "notNull": true | |
| 922 | + }, | |
| 923 | + "owner": { | |
| 924 | + "name": "owner", | |
| 925 | + "type": "varchar(255)", | |
| 926 | + "primaryKey": false, | |
| 927 | + "notNull": true | |
| 928 | + }, | |
| 929 | + "subject": { | |
| 930 | + "name": "subject", | |
| 931 | + "type": "varchar(255)", | |
| 932 | + "primaryKey": false, | |
| 933 | + "notNull": true | |
| 934 | + } | |
| 935 | + }, | |
| 936 | + "indexes": { | |
| 937 | + "plugin_storage_uidx": { | |
| 938 | + "name": "plugin_storage_uidx", | |
| 939 | + "columns": [ | |
| 940 | + { | |
| 941 | + "expression": "tenant", | |
| 942 | + "isExpression": false, | |
| 943 | + "asc": true, | |
| 944 | + "nulls": "last" | |
| 945 | + }, | |
| 946 | + { | |
| 947 | + "expression": "owner", | |
| 948 | + "isExpression": false, | |
| 949 | + "asc": true, | |
| 950 | + "nulls": "last" | |
| 951 | + }, | |
| 952 | + { | |
| 953 | + "expression": "subject", | |
| 954 | + "isExpression": false, | |
| 955 | + "asc": true, | |
| 956 | + "nulls": "last" | |
| 957 | + }, | |
| 958 | + { | |
| 959 | + "expression": "plugin_id", | |
| 960 | + "isExpression": false, | |
| 961 | + "asc": true, | |
| 962 | + "nulls": "last" | |
| 963 | + }, | |
| 964 | + { | |
| 965 | + "expression": "collection", | |
| 966 | + "isExpression": false, | |
| 967 | + "asc": true, | |
| 968 | + "nulls": "last" | |
| 969 | + }, | |
| 970 | + { | |
| 971 | + "expression": "key", | |
| 972 | + "isExpression": false, | |
| 973 | + "asc": true, | |
| 974 | + "nulls": "last" | |
| 975 | + } | |
| 976 | + ], | |
| 977 | + "isUnique": true, | |
| 978 | + "concurrently": false, | |
| 979 | + "method": "btree", | |
| 980 | + "with": {} | |
| 981 | + } | |
| 982 | + }, | |
| 983 | + "foreignKeys": {}, | |
| 984 | + "compositePrimaryKeys": {}, | |
| 985 | + "uniqueConstraints": {}, | |
| 986 | + "policies": {}, | |
| 987 | + "checkConstraints": {}, | |
| 988 | + "isRLSEnabled": false | |
| 989 | + }, | |
| 990 | + "public.private_executor_cloud_settings": { | |
| 991 | + "name": "private_executor_cloud_settings", | |
| 992 | + "schema": "", | |
| 993 | + "columns": { | |
| 994 | + "id": { | |
| 995 | + "name": "id", | |
| 996 | + "type": "varchar(255)", | |
| 997 | + "primaryKey": true, | |
| 998 | + "notNull": true | |
| 999 | + }, | |
| 1000 | + "version": { | |
| 1001 | + "name": "version", | |
| 1002 | + "type": "varchar(255)", | |
| 1003 | + "primaryKey": false, | |
| 1004 | + "notNull": true, | |
| 1005 | + "default": "'1.0.0'" | |
| 1006 | + } | |
| 1007 | + }, | |
| 1008 | + "indexes": {}, | |
| 1009 | + "foreignKeys": {}, | |
| 1010 | + "compositePrimaryKeys": {}, | |
| 1011 | + "uniqueConstraints": {}, | |
| 1012 | + "policies": {}, | |
| 1013 | + "checkConstraints": {}, | |
| 1014 | + "isRLSEnabled": false | |
| 1015 | + }, | |
| 1016 | + "public.tool": { | |
| 1017 | + "name": "tool", | |
| 1018 | + "schema": "", | |
| 1019 | + "columns": { | |
| 1020 | + "integration": { | |
| 1021 | + "name": "integration", | |
| 1022 | + "type": "varchar(255)", | |
| 1023 | + "primaryKey": false, | |
| 1024 | + "notNull": true | |
| 1025 | + }, | |
| 1026 | + "connection": { | |
| 1027 | + "name": "connection", | |
| 1028 | + "type": "varchar(255)", | |
| 1029 | + "primaryKey": false, | |
| 1030 | + "notNull": true | |
| 1031 | + }, | |
| 1032 | + "plugin_id": { | |
| 1033 | + "name": "plugin_id", | |
| 1034 | + "type": "text", | |
| 1035 | + "primaryKey": false, | |
| 1036 | + "notNull": true | |
| 1037 | + }, | |
| 1038 | + "name": { | |
| 1039 | + "name": "name", | |
| 1040 | + "type": "varchar(255)", | |
| 1041 | + "primaryKey": false, | |
| 1042 | + "notNull": true | |
| 1043 | + }, | |
| 1044 | + "description": { | |
| 1045 | + "name": "description", | |
| 1046 | + "type": "text", | |
| 1047 | + "primaryKey": false, | |
| 1048 | + "notNull": true | |
| 1049 | + }, | |
| 1050 | + "input_schema": { | |
| 1051 | + "name": "input_schema", | |
| 1052 | + "type": "json", | |
| 1053 | + "primaryKey": false, | |
| 1054 | + "notNull": false | |
| 1055 | + }, | |
| 1056 | + "output_schema": { | |
| 1057 | + "name": "output_schema", | |
| 1058 | + "type": "json", | |
| 1059 | + "primaryKey": false, | |
| 1060 | + "notNull": false | |
| 1061 | + }, | |
| 1062 | + "annotations": { | |
| 1063 | + "name": "annotations", | |
| 1064 | + "type": "json", | |
| 1065 | + "primaryKey": false, | |
| 1066 | + "notNull": false | |
| 1067 | + }, | |
| 1068 | + "created_at": { | |
| 1069 | + "name": "created_at", | |
| 1070 | + "type": "timestamp", | |
| 1071 | + "primaryKey": false, | |
| 1072 | + "notNull": true | |
| 1073 | + }, | |
| 1074 | + "updated_at": { | |
| 1075 | + "name": "updated_at", | |
| 1076 | + "type": "timestamp", | |
| 1077 | + "primaryKey": false, | |
| 1078 | + "notNull": true | |
| 1079 | + }, | |
| 1080 | + "row_id": { | |
| 1081 | + "name": "row_id", | |
| 1082 | + "type": "varchar(255)", | |
| 1083 | + "primaryKey": true, | |
| 1084 | + "notNull": true | |
| 1085 | + }, | |
| 1086 | + "tenant": { | |
| 1087 | + "name": "tenant", | |
| 1088 | + "type": "varchar(255)", | |
| 1089 | + "primaryKey": false, | |
| 1090 | + "notNull": true | |
| 1091 | + }, | |
| 1092 | + "owner": { | |
| 1093 | + "name": "owner", | |
| 1094 | + "type": "varchar(255)", | |
| 1095 | + "primaryKey": false, | |
| 1096 | + "notNull": true | |
| 1097 | + }, | |
| 1098 | + "subject": { | |
| 1099 | + "name": "subject", | |
| 1100 | + "type": "varchar(255)", | |
| 1101 | + "primaryKey": false, | |
| 1102 | + "notNull": true | |
| 1103 | + } | |
| 1104 | + }, | |
| 1105 | + "indexes": { | |
| 1106 | + "tool_uidx": { | |
| 1107 | + "name": "tool_uidx", | |
| 1108 | + "columns": [ | |
| 1109 | + { | |
| 1110 | + "expression": "tenant", | |
| 1111 | + "isExpression": false, | |
| 1112 | + "asc": true, | |
| 1113 | + "nulls": "last" | |
| 1114 | + }, | |
| 1115 | + { | |
| 1116 | + "expression": "owner", | |
| 1117 | + "isExpression": false, | |
| 1118 | + "asc": true, | |
| 1119 | + "nulls": "last" | |
| 1120 | + }, | |
| 1121 | + { | |
| 1122 | + "expression": "subject", | |
| 1123 | + "isExpression": false, | |
| 1124 | + "asc": true, | |
| 1125 | + "nulls": "last" | |
| 1126 | + }, | |
| 1127 | + { | |
| 1128 | + "expression": "integration", | |
| 1129 | + "isExpression": false, | |
| 1130 | + "asc": true, | |
| 1131 | + "nulls": "last" | |
| 1132 | + }, | |
| 1133 | + { | |
| 1134 | + "expression": "connection", | |
| 1135 | + "isExpression": false, | |
| 1136 | + "asc": true, | |
| 1137 | + "nulls": "last" | |
| 1138 | + }, | |
| 1139 | + { | |
| 1140 | + "expression": "name", | |
| 1141 | + "isExpression": false, | |
| 1142 | + "asc": true, | |
| 1143 | + "nulls": "last" | |
| 1144 | + } | |
| 1145 | + ], | |
| 1146 | + "isUnique": true, | |
| 1147 | + "concurrently": false, | |
| 1148 | + "method": "btree", | |
| 1149 | + "with": {} | |
| 1150 | + } | |
| 1151 | + }, | |
| 1152 | + "foreignKeys": {}, | |
| 1153 | + "compositePrimaryKeys": {}, | |
| 1154 | + "uniqueConstraints": {}, | |
| 1155 | + "policies": {}, | |
| 1156 | + "checkConstraints": {}, | |
| 1157 | + "isRLSEnabled": false | |
| 1158 | + }, | |
| 1159 | + "public.tool_policy": { | |
| 1160 | + "name": "tool_policy", | |
| 1161 | + "schema": "", | |
| 1162 | + "columns": { | |
| 1163 | + "id": { | |
| 1164 | + "name": "id", | |
| 1165 | + "type": "varchar(255)", | |
| 1166 | + "primaryKey": false, | |
| 1167 | + "notNull": true | |
| 1168 | + }, | |
| 1169 | + "pattern": { | |
| 1170 | + "name": "pattern", | |
| 1171 | + "type": "text", | |
| 1172 | + "primaryKey": false, | |
| 1173 | + "notNull": true | |
| 1174 | + }, | |
| 1175 | + "action": { | |
| 1176 | + "name": "action", | |
| 1177 | + "type": "text", | |
| 1178 | + "primaryKey": false, | |
| 1179 | + "notNull": true | |
| 1180 | + }, | |
| 1181 | + "position": { | |
| 1182 | + "name": "position", | |
| 1183 | + "type": "text", | |
| 1184 | + "primaryKey": false, | |
| 1185 | + "notNull": true | |
| 1186 | + }, | |
| 1187 | + "created_at": { | |
| 1188 | + "name": "created_at", | |
| 1189 | + "type": "timestamp", | |
| 1190 | + "primaryKey": false, | |
| 1191 | + "notNull": true | |
| 1192 | + }, | |
| 1193 | + "updated_at": { | |
| 1194 | + "name": "updated_at", | |
| 1195 | + "type": "timestamp", | |
| 1196 | + "primaryKey": false, | |
| 1197 | + "notNull": true | |
| 1198 | + }, | |
| 1199 | + "row_id": { | |
| 1200 | + "name": "row_id", | |
| 1201 | + "type": "varchar(255)", | |
| 1202 | + "primaryKey": true, | |
| 1203 | + "notNull": true | |
| 1204 | + }, | |
| 1205 | + "tenant": { | |
| 1206 | + "name": "tenant", | |
| 1207 | + "type": "varchar(255)", | |
| 1208 | + "primaryKey": false, | |
| 1209 | + "notNull": true | |
| 1210 | + }, | |
| 1211 | + "owner": { | |
| 1212 | + "name": "owner", | |
| 1213 | + "type": "varchar(255)", | |
| 1214 | + "primaryKey": false, | |
| 1215 | + "notNull": true | |
| 1216 | + }, | |
| 1217 | + "subject": { | |
| 1218 | + "name": "subject", | |
| 1219 | + "type": "varchar(255)", | |
| 1220 | + "primaryKey": false, | |
| 1221 | + "notNull": true | |
| 1222 | + } | |
| 1223 | + }, | |
| 1224 | + "indexes": { | |
| 1225 | + "tool_policy_uidx": { | |
| 1226 | + "name": "tool_policy_uidx", | |
| 1227 | + "columns": [ | |
| 1228 | + { | |
| 1229 | + "expression": "tenant", | |
| 1230 | + "isExpression": false, | |
| 1231 | + "asc": true, | |
| 1232 | + "nulls": "last" | |
| 1233 | + }, | |
| 1234 | + { | |
| 1235 | + "expression": "owner", | |
| 1236 | + "isExpression": false, | |
| 1237 | + "asc": true, | |
| 1238 | + "nulls": "last" | |
| 1239 | + }, | |
| 1240 | + { | |
| 1241 | + "expression": "subject", | |
| 1242 | + "isExpression": false, | |
| 1243 | + "asc": true, | |
| 1244 | + "nulls": "last" | |
| 1245 | + }, | |
| 1246 | + { | |
| 1247 | + "expression": "id", | |
| 1248 | + "isExpression": false, | |
| 1249 | + "asc": true, | |
| 1250 | + "nulls": "last" | |
| 1251 | + } | |
| 1252 | + ], | |
| 1253 | + "isUnique": true, | |
| 1254 | + "concurrently": false, | |
| 1255 | + "method": "btree", | |
| 1256 | + "with": {} | |
| 1257 | + } | |
| 1258 | + }, | |
| 1259 | + "foreignKeys": {}, | |
| 1260 | + "compositePrimaryKeys": {}, | |
| 1261 | + "uniqueConstraints": {}, | |
| 1262 | + "policies": {}, | |
| 1263 | + "checkConstraints": {}, | |
| 1264 | + "isRLSEnabled": false | |
| 1265 | + } | |
| 1266 | + }, | |
| 1267 | + "enums": {}, | |
| 1268 | + "schemas": {}, | |
| 1269 | + "sequences": {}, | |
| 1270 | + "roles": {}, | |
| 1271 | + "policies": {}, | |
| 1272 | + "views": {}, | |
| 1273 | + "_meta": { | |
| 1274 | + "columns": {}, | |
| 1275 | + "schemas": {}, | |
| 1276 | + "tables": {} | |
| 1277 | + } | |
| 1278 | +} | |
| @@ -57,6 +57,13 @@ · 56 unmodified lines | ||
| 57 | 57 | "when": 1782149425764, |
| 58 | 58 | "tag": "0007_red_dragon_lord", |
| 59 | 59 | "breakpoints": true |
| 60 | + }, | |
| 61 | + { | |
| 62 | + "idx": 8, | |
| 63 | + "version": "7", | |
| 64 | + "when": 1783025157232, | |
| 65 | + "tag": "0008_slow_mephisto", | |
| 66 | + "breakpoints": true | |
| 60 | 67 | } |
| 61 | 68 | ] |
| 62 | 69 | } |
| @@ -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 | 1 | import type { CodeMigration } from "./runner"; |
| 2 | 2 | import { googleOpenApiR2BlobMigration } from "./google-openapi-r2-blobs"; |
| 3 | +import { gcDeadDcrOAuthClientsMigration } from "./gc-dead-dcr-oauth-clients"; | |
| 3 | 4 | |
| 4 | 5 | export interface CloudCodeMigrationRegistryOptions { |
| 5 | 6 | readonly r2Bucket?: string; |
| @@ -11,6 +12,9 @@ export const cloudCodeMigrations = ({ · 5 unmodified lines | ||
| 11 | 12 | limit, |
| 12 | 13 | }: CloudCodeMigrationRegistryOptions): readonly CodeMigration[] => [ |
| 13 | 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 | 20 | export { runCodeMigrations } from "./runner"; |
| @@ -83,6 +83,7 @@ export const oauth_client = pgTable( · 82 unmodified lines | ||
| 83 | 83 | resource: text("resource"), |
| 84 | 84 | origin_kind: text("origin_kind"), |
| 85 | 85 | origin_integration: text("origin_integration"), |
| 86 | + origin_issuer: text("origin_issuer"), | |
| 86 | 87 | created_at: timestamp("created_at").notNull(), |
| 87 | 88 | row_id: varchar("row_id", { length: 255 }) |
| 88 | 89 | .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 | 28 | import { AutumnService } from "../extensions/billing/service"; |
| 29 | 29 | import type { DbService } from "../db/db"; |
| 30 | 30 | import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; |
| 31 | +import { makeExecutionLimitGate } from "./execution-gate"; | |
| 32 | +import { makeCloudExecutionRateLimiter } from "./execution-rate-limit"; | |
| 31 | 33 | import { withExecutionUsageTracking } from "./execution-usage"; |
| 32 | 34 | |
| 33 | −// Usage-metering decorator bound to the billing service. `trackExecution` is | |
| 34 | −// fire-and-forget (`Effect.runFork`) so the billing call can't stall a | |
| 35 | −// user-facing execution. | |
| 35 | +// Usage-metering decorator bound to the billing service, plus the two | |
| 36 | +// pre-execution guards this layer owns, ordered cheapest first: | |
| 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 | 45 | export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> = |
| 37 | 46 | Layer.effect(EngineDecorator)( |
| 38 | − Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => ({ | |
| 39 | − decorate: (engine, identity: EngineStackIdentity) => | |
| 40 | − withExecutionUsageTracking(identity.organizationId, engine, (organizationId) => | |
| 41 | − Effect.runFork(autumn.trackExecution(organizationId)), | |
| 42 | − ), | |
| 43 | − })), | |
| 47 | + Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => { | |
| 48 | + const balanceGate = makeExecutionLimitGate((organizationId) => | |
| 49 | + autumn.checkExecutionBalance(organizationId), | |
| 50 | + ); | |
| 51 | + const rateLimiter = makeCloudExecutionRateLimiter(); | |
| 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 | 32 | // ON; the test workers set "true" so fixtures can reach localhost. |
| 33 | 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 | 49 | // Billing |
| 36 | 50 | AUTUMN_SECRET_KEY?: string; |
| 37 | 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 | 23 | export type IAutumnService = Readonly<{ |
| 24 | 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 | 29 | * Fire-and-forget-safe execution usage tracker. Errors are caught and |
| 27 | 30 | * logged; the returned Effect never fails. Callers typically |
| @@ -44,6 +47,7 @@ const make = Effect.sync(() => { · 16 unmodified lines | ||
| 44 | 47 | ); |
| 45 | 48 | return { |
| 46 | 49 | use: () => notConfigured, |
| 50 | + checkExecutionBalance: () => notConfigured, | |
| 47 | 51 | trackExecution: () => Effect.void, |
| 48 | 52 | } satisfies IAutumnService; |
| 49 | 53 | } |
| @@ -80,7 +84,16 @@ const make = Effect.sync(() => { · 30 unmodified lines | ||
| 80 | 84 | ); |
| 81 | 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 | 99 | export class AutumnService extends Context.Service<AutumnService, IAutumnService>()( |
| @@ -55,6 +55,11 @@ export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( · 54 unmodified lines | ||
| 55 | 55 | // (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it. |
| 56 | 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 | 64 | // Worker fetch handler |
| 60 | 65 | // |
| @@ -13,7 +13,7 @@ const supportLinks = [ · 12 unmodified lines | ||
| 13 | 13 | { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark }, |
| 14 | 14 | { |
| 15 | 15 | label: "GitHub Issues", |
| 16 | − href: "https://github.com/RhysSullivan/executor/issues", | |
| 16 | + href: "https://github.com/UsefulSoftwareCo/executor/issues", | |
| 17 | 17 | icon: GitHubMark, |
| 18 | 18 | }, |
| 19 | 19 | { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark }, |
| @@ -22,6 +22,13 @@ · 21 unmodified lines | ||
| 22 | 22 | "name": "MCP_SESSION", |
| 23 | 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 | 34 | // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which |
| @@ -42,6 +49,10 @@ · 14 unmodified lines | ||
| 42 | 49 | "tag": "v2", |
| 43 | 50 | "new_sqlite_classes": ["McpSessionDOSqlite"], |
| 44 | 51 | }, |
| 52 | + { | |
| 53 | + "tag": "v3", | |
| 54 | + "new_sqlite_classes": ["ExecutionRateLimiterDO"], | |
| 55 | + }, | |
| 45 | 56 | ], |
| 46 | 57 | "services": [ |
| 47 | 58 | { |
| @@ -57,7 +57,7 @@ const config: Configuration = { · 56 unmodified lines | ||
| 57 | 57 | }, |
| 58 | 58 | publish: { |
| 59 | 59 | provider: "github", |
| 60 | − owner: "RhysSullivan", | |
| 60 | + owner: "UsefulSoftwareCo", | |
| 61 | 61 | repo: "executor", |
| 62 | 62 | }, |
| 63 | 63 | }; |
| @@ -53,7 +53,9 @@ export default defineConfig({ · 52 unmodified lines | ||
| 53 | 53 | "import.meta.env.VITE_APP_VERSION": JSON.stringify( |
| 54 | 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 | 59 | "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify( |
| 58 | 60 | process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh", |
| 59 | 61 | ), |
| @@ -2,7 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | "name": "@executor-js/desktop", |
| 3 | 3 | "version": "1.5.27", |
| 4 | 4 | "private": true, |
| 5 | − "homepage": "https://github.com/RhysSullivan/executor", | |
| 5 | + "homepage": "https://github.com/UsefulSoftwareCo/executor", | |
| 6 | 6 | "license": "MIT", |
| 7 | 7 | "type": "module", |
| 8 | 8 | "main": "./out/main/index.js", |
| @@ -259,7 +259,7 @@ export const reportAProblem = async () => { · 258 unmodified lines | ||
| 259 | 259 | "", |
| 260 | 260 | "_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._", |
| 261 | 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 | 263 | url.searchParams.set("title", "[desktop] "); |
| 264 | 264 | url.searchParams.set("body", body); |
| 265 | 265 | await shell.openExternal(url.toString()); |
| @@ -45,7 +45,7 @@ · 44 unmodified lines | ||
| 45 | 45 | "links": [ |
| 46 | 46 | { |
| 47 | 47 | "label": "GitHub", |
| 48 | − "href": "https://github.com/RhysSullivan/executor" | |
| 48 | + "href": "https://github.com/UsefulSoftwareCo/executor" | |
| 49 | 49 | } |
| 50 | 50 | ], |
| 51 | 51 | "primary": { |
| @@ -56,7 +56,7 @@ · 4 unmodified lines | ||
| 56 | 56 | }, |
| 57 | 57 | "footer": { |
| 58 | 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 | 15 | ## Prerequisites |
| 16 | 16 | |
| 17 | 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 | 19 | dependencies installed (`bun install`). |
| 20 | 20 | |
| 21 | 21 | ## Deploy |
| @@ -17,10 +17,10 @@ docker run -d \ · 16 unmodified lines | ||
| 17 | 17 | --name executor-selfhost \ |
| 18 | 18 | -p 4788:4788 \ |
| 19 | 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 | 24 | from `apps/host-selfhost`: |
| 25 | 25 | |
| 26 | 26 | ```bash |
| @@ -81,7 +81,7 @@ docker run -d \ · 54 unmodified lines | ||
| 81 | 81 | -p 4788:4788 \ |
| 82 | 82 | -v executor-data:/data \ |
| 83 | 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 | 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 | 54 | 3. Once the tools are available, help me add my first integration and get one tool working end to end. |
| 55 | 55 | |
| 56 | 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 | 60 | ## Concepts |
| @@ -50,7 +50,9 @@ export default defineConfig({ · 49 unmodified lines | ||
| 50 | 50 | // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm, |
| 51 | 51 | // so the update card links to the upgrade guide instead of a command. |
| 52 | 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 | 56 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"), |
| 55 | 57 | }, |
| 56 | 58 | server: { |
| @@ -23,7 +23,7 @@ RUN cd apps/host-selfhost && bun run build · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | FROM gcr.io/distroless/cc-debian12 AS runtime |
| 25 | 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 | 27 | org.opencontainers.image.description="Single-container self-hosted Executor" \ |
| 28 | 28 | org.opencontainers.image.licenses="MIT" |
| 29 | 29 | ENV NODE_ENV=production \ |
| @@ -14,7 +14,7 @@ docker run -d \ · 13 unmodified lines | ||
| 14 | 14 | --name executor-selfhost \ |
| 15 | 15 | -p 4788:4788 \ |
| 16 | 16 | -v executor-data:/data \ |
| 17 | − ghcr.io/rhyssullivan/executor-selfhost:latest | |
| 17 | + ghcr.io/usefulsoftwareco/executor-selfhost:latest | |
| 18 | 18 | ``` |
| 19 | 19 | |
| 20 | 20 | Or build from a repository clone: |
| @@ -195,7 +195,9 @@ export default defineConfig({ · 194 unmodified lines | ||
| 195 | 195 | // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not |
| 196 | 196 | // npm, so the update card links to the upgrade guide instead of a command. |
| 197 | 197 | "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"), |
| 198 | − "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), | |
| 198 | + "import.meta.env.VITE_GITHUB_URL": JSON.stringify( | |
| 199 | + "https://github.com/UsefulSoftwareCo/executor", | |
| 200 | + ), | |
| 199 | 201 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), |
| 200 | 202 | }, |
| 201 | 203 | 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 | 22 | "when": 1782150869757, |
| 23 | 23 | "tag": "0002_empty_rictor", |
| 24 | 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 | 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 | 14 | import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth"; |
| 10 | 15 | import { |
| 11 | 16 | openApiOutputSchemaDataMigration, |
| @@ -37,4 +42,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 25 unmodified lines | ||
| 37 | 42 | openApiSpecBlobDataMigration, |
| 38 | 43 | graphqlIntrospectionBlobDataMigration, |
| 39 | 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 | 75 | resource: text("resource"), |
| 76 | 76 | origin_kind: text("origin_kind"), |
| 77 | 77 | origin_integration: text("origin_integration"), |
| 78 | + origin_issuer: text("origin_issuer"), | |
| 78 | 79 | created_at: integer("created_at").notNull(), |
| 79 | 80 | row_id: text("row_id").primaryKey().notNull(), |
| 80 | 81 | tenant: text("tenant").notNull(), |
| @@ -93,6 +93,12 @@ export const createSqliteFumaDb = async <const TTables extends FumaTables>( · 92 unmodified lines | ||
| 93 | 93 | ) { |
| 94 | 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 | 103 | const { db, fuma } = createExecutorFumaDb(drizzleDb, { |
| 98 | 104 | tables: options.tables, |
| @@ -34,7 +34,7 @@ const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; · 33 unmodified lines | ||
| 34 | 34 | const EXECUTOR_GITHUB_URL = ( |
| 35 | 35 | rootPackage.homepage ?? |
| 36 | 36 | repositoryUrl ?? |
| 37 | − "https://github.com/RhysSullivan/executor" | |
| 37 | + "https://github.com/UsefulSoftwareCo/executor" | |
| 38 | 38 | ) |
| 39 | 39 | .replace(/^git\+/, "") |
| 40 | 40 | .replace(/\.git$/, ""); |
| @@ -56,7 +56,7 @@ const { title, description, lastUpdated } = Astro.props; · 55 unmodified lines | ||
| 56 | 56 | <a href="/privacy" class="text-ink-faint transition-colors hover:text-ink">Privacy</a> |
| 57 | 57 | <a href="/terms" class="text-ink-faint transition-colors hover:text-ink">Terms</a> |
| 58 | 58 | <a |
| 59 | − href="https://github.com/RhysSullivan/executor" | |
| 59 | + href="https://github.com/UsefulSoftwareCo/executor" | |
| 60 | 60 | class="text-ink-faint transition-colors hover:text-ink" |
| 61 | 61 | > |
| 62 | 62 | GitHub |
| @@ -21,7 +21,7 @@ const { · 20 unmodified lines | ||
| 21 | 21 | authorAvatar, |
| 22 | 22 | } = Astro.props; |
| 23 | 23 | |
| 24 | −const GH = "https://github.com/RhysSullivan/executor"; | |
| 24 | +const GH = "https://github.com/UsefulSoftwareCo/executor"; | |
| 25 | 25 | const authorX = `https://x.com/${authorHandle}`; |
| 26 | 26 | |
| 27 | 27 | const formattedDate = date.toLocaleDateString("en-US", { |
| @@ -11,7 +11,7 @@ · 10 unmodified lines | ||
| 11 | 11 | // - resolve to null on any failure, in which case the nav simply drops the |
| 12 | 12 | // count and keeps the link. |
| 13 | 13 | |
| 14 | −const REPO = "RhysSullivan/executor"; | |
| 14 | +const REPO = "UsefulSoftwareCo/executor"; | |
| 15 | 15 | |
| 16 | 16 | export const GITHUB_REPO_URL = `https://github.com/${REPO}`; |
| 17 | 17 | |
| @@ -3,7 +3,7 @@ export const prerender = true; · 2 unmodified lines | ||
| 3 | 3 | import Layout from "../../layouts/Layout.astro"; |
| 4 | 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 | 8 | const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort( |
| 9 | 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 | 8 | import ycBackedBy from "../assets/yc-backed-by.svg?url"; |
| 9 | 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 | 12 | // Cloud sign-in. Same origin in prod: the cloud Worker owns executor.sh and |
| 13 | 13 | // /api/* is app-owned (apps/cloud/src/app-paths.ts), so this hits the WorkOS |
| 14 | 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 | 50 | 3. Once the tools are available, help me add my first integration and get one tool working end to end. |
| 51 | 51 | |
| 52 | 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 | 56 | <Layout> |
| @@ -344,7 +344,7 @@ · 343 unmodified lines | ||
| 344 | 344 | "version": "0.0.24", |
| 345 | 345 | "dependencies": { |
| 346 | 346 | "@executor-js/api": "workspace:*", |
| 347 | − "@executor-js/emulate": "^0.10.0", | |
| 347 | + "@executor-js/emulate": "^0.13.2", | |
| 348 | 348 | "@executor-js/mcporter": "^0.11.4", |
| 349 | 349 | "@executor-js/plugin-graphql": "workspace:*", |
| 350 | 350 | "@executor-js/plugin-mcp": "workspace:*", |
| @@ -582,6 +582,7 @@ · 231 unmodified lines | ||
| 582 | 582 | "@standard-schema/spec": "^1.1.0", |
| 583 | 583 | "fractional-indexing": "^3.2.0", |
| 584 | 584 | "oauth4webapi": "^3.8.5", |
| 585 | + "tldts": "^7.0.28", | |
| 585 | 586 | }, |
| 586 | 587 | "devDependencies": { |
| 587 | 588 | "@effect/atom-react": "catalog:", |
| @@ -1743,7 +1744,7 @@ · 1155 unmodified lines | ||
| 1743 | 1744 | |
| 1744 | 1745 | "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"], |
| 1745 | 1746 | |
| 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=="], | |
| 1747 | + "@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 | 1748 | |
| 1748 | 1749 | "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"], |
| 1749 | 1750 | |
| @@ -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 | 23 | "dependencies": { |
| 24 | 24 | "@executor-js/api": "workspace:*", |
| 25 | − "@executor-js/emulate": "^0.10.0", | |
| 25 | + "@executor-js/emulate": "^0.13.2", | |
| 26 | 26 | "@executor-js/mcporter": "^0.11.4", |
| 27 | 27 | "@executor-js/plugin-graphql": "workspace:*", |
| 28 | 28 | "@executor-js/plugin-mcp": "workspace:*", |
| @@ -49,3 +49,21 @@ const toSeed = (plan: AtmnPlan): AutumnSeedPlan => ({ · 48 unmodified lines | ||
| 49 | 49 | export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) => |
| 50 | 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 | 13 | import { bootProcesses, waitForHttp } from "./boot"; |
| 14 | 14 | import { AUTUMN_PLAN_SEED } from "./autumn-plans"; |
| 15 | +import { E2E_EXECUTION_RATE_LIMIT } from "./execution-limits"; | |
| 15 | 16 | |
| 16 | 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 | 91 | MCP_AUTHKIT_DOMAIN: workosUrl, |
| 91 | 92 | MCP_RESOURCE_ORIGIN: options.publicUrl, |
| 92 | 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 | 100 | // Throwaway PGlite on its own port + dir so it never fights `bun dev`. |
| 94 | 101 | DEV_DB_PORT: String(options.dbPort), |
| 95 | 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 | 9 | // so the contract has to be pinned where the usage is read. |
| 10 | 10 | import { Effect, Schedule } from "effect"; |
| 11 | 11 | |
| 12 | +import { DEFAULT_PLAN_EXECUTIONS_INCLUDED } from "../../setup/autumn-plans"; | |
| 13 | + | |
| 12 | 14 | /** One usage event Autumn recorded: a `track` of `value` units of `featureId` |
| 13 | 15 | * for `customerId` (the organization the execution ran under). */ |
| 14 | 16 | export interface UsageEvent { |
| @@ -24,6 +26,33 @@ export interface UsageQuery { · 9 unmodified lines | ||
| 24 | 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 | 56 | export interface AutumnSurface { |
| 28 | 57 | /** One-shot read of the matching usage events from the ledger. */ |
| 29 | 58 | readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>; |
| @@ -41,6 +70,20 @@ export interface AutumnSurface { · 11 unmodified lines | ||
| 41 | 70 | * the billing backend becomes consistent. The `sessionId` is the last path |
| 42 | 71 | * segment of the hosted checkout URL the browser was sent to. */ |
| 43 | 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 | 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 | 212 | return { |
| 94 | 213 | usageEvents, |
| 95 | 214 | settleCheckout, |
| 215 | + exhaustExecutions, | |
| 216 | + armFault, | |
| 217 | + clearFaults, | |
| 218 | + ledgerFor, | |
| 96 | 219 | expectUsage: (query) => |
| 97 | 220 | usageEvents(query).pipe( |
| 98 | 221 | Effect.filterOrFail( |
| @@ -11,14 +11,14 @@ · 10 unmodified lines | ||
| 11 | 11 | "executor", |
| 12 | 12 | "local-first" |
| 13 | 13 | ], |
| 14 | − "homepage": "https://github.com/RhysSullivan/executor", | |
| 14 | + "homepage": "https://github.com/UsefulSoftwareCo/executor", | |
| 15 | 15 | "bugs": { |
| 16 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 16 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 17 | 17 | }, |
| 18 | 18 | "license": "MIT", |
| 19 | 19 | "repository": { |
| 20 | 20 | "type": "git", |
| 21 | − "url": "git+https://github.com/RhysSullivan/executor.git" | |
| 21 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git" | |
| 22 | 22 | }, |
| 23 | 23 | "workspaces": [ |
| 24 | 24 | "packages/*/*", |
| @@ -13,7 +13,9 @@ export default defineConfig({ · 12 unmodified lines | ||
| 13 | 13 | plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })], |
| 14 | 14 | define: { |
| 15 | 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 | 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 | 103 | clientId: payload.clientId, |
| 104 | 104 | clientSecret: payload.clientSecret, |
| 105 | 105 | resource: payload.resource ?? null, |
| 106 | + origin: { kind: "manual", integration: payload.originIntegration ?? null }, | |
| 106 | 107 | }); |
| 107 | 108 | return { client }; |
| 108 | 109 | }), |
| @@ -115,6 +116,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 6 unmodified lines | ||
| 115 | 116 | const client = yield* executor.oauth.registerDynamicClient({ |
| 116 | 117 | owner: payload.owner, |
| 117 | 118 | slug: payload.slug, |
| 119 | + issuer: payload.issuer ?? null, | |
| 118 | 120 | registrationEndpoint: payload.registrationEndpoint, |
| 119 | 121 | authorizationUrl: payload.authorizationUrl, |
| 120 | 122 | tokenUrl: payload.tokenUrl, |
| @@ -65,6 +65,9 @@ const CreateClientPayload = Schema.Struct({ · 64 unmodified lines | ||
| 65 | 65 | clientId: Schema.String, |
| 66 | 66 | clientSecret: Schema.String, |
| 67 | 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 | 73 | const CreateClientResponse = Schema.Struct({ |
| @@ -81,6 +84,7 @@ const CreateClientResponse = Schema.Struct({ · 10 unmodified lines | ||
| 81 | 84 | const RegisterDynamicPayload = Schema.Struct({ |
| 82 | 85 | owner: Owner, |
| 83 | 86 | slug: OAuthClientSlug, |
| 87 | + issuer: Schema.optional(Schema.NullOr(Schema.String)), | |
| 84 | 88 | registrationEndpoint: Schema.String, |
| 85 | 89 | authorizationUrl: Schema.String, |
| 86 | 90 | tokenUrl: Schema.String, |
| @@ -203,6 +207,7 @@ const ProbePayload = Schema.Struct({ · 116 unmodified lines | ||
| 203 | 207 | }); |
| 204 | 208 | |
| 205 | 209 | const ProbeResponse = Schema.Struct({ |
| 210 | + issuer: Schema.optional(Schema.NullOr(Schema.String)), | |
| 206 | 211 | authorizationUrl: Schema.String, |
| 207 | 212 | tokenUrl: Schema.String, |
| 208 | 213 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -2,14 +2,14 @@ · 1 unmodified line | ||
| 2 | 2 | "name": "@executor-js/cli", |
| 3 | 3 | "version": "0.2.34", |
| 4 | 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 | 6 | "bugs": { |
| 7 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 7 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 8 | 8 | }, |
| 9 | 9 | "license": "MIT", |
| 10 | 10 | "repository": { |
| 11 | 11 | "type": "git", |
| 12 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 12 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 13 | 13 | "directory": "packages/core/cli" |
| 14 | 14 | }, |
| 15 | 15 | "bin": { |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/config", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/core/config" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -126,7 +126,7 @@ import { createExecutionEngine } from "@executor-js/execution/core"; · 125 unmodified lines | ||
| 126 | 126 | |
| 127 | 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 | 131 | ## License |
| 132 | 132 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/execution", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/core/execution" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | "version": "1.5.7", |
| 5 | 5 | "repository": { |
| 6 | 6 | "type": "git", |
| 7 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 7 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 8 | 8 | "directory": "packages/core/fumadb" |
| 9 | 9 | }, |
| 10 | 10 | "author": "Fuma Nama", |
| @@ -2,14 +2,14 @@ · 1 unmodified line | ||
| 2 | 2 | "name": "@executor-js/integrations-registry", |
| 3 | 3 | "version": "0.1.0", |
| 4 | 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 | 6 | "bugs": { |
| 7 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 7 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 8 | 8 | }, |
| 9 | 9 | "license": "MIT", |
| 10 | 10 | "repository": { |
| 11 | 11 | "type": "git", |
| 12 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 12 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 13 | 13 | "directory": "packages/core/integrations-registry" |
| 14 | 14 | }, |
| 15 | 15 | "files": [ |
| @@ -126,7 +126,7 @@ console.log(await executor.secrets.get("api-token")); // "sk_live_xxx" · 125 unmodified lines | ||
| 126 | 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 | 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 | 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 | 170 | ## License |
| 171 | 171 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/sdk", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/core/sdk" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -96,7 +96,8 @@ · 81 unmodified lines | ||
| 96 | 96 | "@executor-js/fumadb": "workspace:*", |
| 97 | 97 | "@standard-schema/spec": "^1.1.0", |
| 98 | 98 | "fractional-indexing": "^3.2.0", |
| 99 | − "oauth4webapi": "^3.8.5" | |
| 99 | + "oauth4webapi": "^3.8.5", | |
| 100 | + "tldts": "^7.0.28" | |
| 100 | 101 | }, |
| 101 | 102 | "devDependencies": { |
| 102 | 103 | "@effect/atom-react": "catalog:", |
| @@ -212,6 +212,14 @@ export const coreTables = defineTables({ · 211 unmodified lines | ||
| 212 | 212 | // "manual" by the service layer. |
| 213 | 213 | origin_kind: nullableTextColumn("origin_kind"), |
| 214 | 214 | origin_integration: nullableTextColumn("origin_integration"), |
| 215 | + // Authorization-server issuer that owns a DCR client, keying per-AS reuse. | |
| 216 | + // For a NEW DCR registration this is the DISCOVERED OIDC issuer (real | |
| 217 | + // information from the AS metadata, which can legitimately differ from what | |
| 218 | + // token_url would suggest). For a BACKFILLED legacy row it is instead a | |
| 219 | + // derived registrable-origin of token_url (a cache of the pure | |
| 220 | + // `registrableOriginOfUrl`, since no discovered issuer was ever recorded). | |
| 221 | + // Null for manual clients and legacy rows not yet backfilled. | |
| 222 | + origin_issuer: nullableTextColumn("origin_issuer"), | |
| 215 | 223 | created_at: dateColumn("created_at"), |
| 216 | 224 | }, |
| 217 | 225 | ["tenant", "owner", "subject", "slug"], |
| @@ -243,6 +243,9 @@ const OAuthCreateClientInput = Schema.Struct({ · 242 unmodified lines | ||
| 243 | 243 | grant: OAuthGrantSchema, |
| 244 | 244 | clientId: Schema.String, |
| 245 | 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 | 250 | // Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret |
| 248 | 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 | 272 | const OAuthRegisterDynamicInput = Schema.Struct({ |
| 270 | 273 | owner: OwnerSchema, |
| 271 | 274 | slug: Schema.String, |
| 275 | + issuer: Schema.optional(Schema.NullOr(Schema.String)), | |
| 272 | 276 | registrationEndpoint: Schema.String, |
| 273 | 277 | authorizationUrl: Schema.String, |
| 274 | 278 | tokenUrl: Schema.String, |
| @@ -287,6 +291,7 @@ const OAuthProbeInput = Schema.Struct({ · 12 unmodified lines | ||
| 287 | 291 | url: Schema.String, |
| 288 | 292 | }); |
| 289 | 293 | const OAuthProbeOutput = Schema.Struct({ |
| 294 | + issuer: Schema.optional(Schema.NullOr(Schema.String)), | |
| 290 | 295 | authorizationUrl: Schema.String, |
| 291 | 296 | tokenUrl: Schema.String, |
| 292 | 297 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| @@ -711,6 +716,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 418 unmodified lines | ||
| 711 | 716 | // `oauth.clients.createHandoff`. |
| 712 | 717 | clientSecret: "", |
| 713 | 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 | 727 | (client) => ({ client: String(client) }), |
| 716 | 728 | ), |
| @@ -748,6 +760,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 31 unmodified lines | ||
| 748 | 760 | ctx.oauth.registerDynamicClient({ |
| 749 | 761 | owner: input.owner as Owner, |
| 750 | 762 | slug: OAuthClientSlug.make(input.slug), |
| 763 | + issuer: input.issuer ?? null, | |
| 751 | 764 | registrationEndpoint: input.registrationEndpoint, |
| 752 | 765 | authorizationUrl: input.authorizationUrl, |
| 753 | 766 | tokenUrl: input.tokenUrl, |
| @@ -789,6 +802,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 35 unmodified lines | ||
| 789 | 802 | outputSchema: OAuthProbeOutputStd, |
| 790 | 803 | execute: (input: typeof OAuthProbeInput.Type, { ctx }) => |
| 791 | 804 | Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({ |
| 805 | + issuer: result.issuer ?? null, | |
| 792 | 806 | authorizationUrl: result.authorizationUrl, |
| 793 | 807 | tokenUrl: result.tokenUrl, |
| 794 | 808 | resource: result.resource ?? null, |
| @@ -363,6 +363,19 @@ export { · 362 unmodified lines | ||
| 363 | 363 | runSqliteConfigBlobMigration, |
| 364 | 364 | type SqliteConfigBlobMigrationOptions, |
| 365 | 365 | } from "./sqlite-config-blob-migration"; |
| 366 | +// DCR oauth_client GC + issuer backfill: shared classification predicates and | |
| 367 | +// the libSQL boot-migration body (issue #1120, Part C). | |
| 368 | +export { | |
| 369 | + classifyOAuthClientGc, | |
| 370 | + isDcrClassifiedRow, | |
| 371 | + registrableOriginOfUrl, | |
| 372 | + type OAuthClientGcDecision, | |
| 373 | + type OAuthClientGcRow, | |
| 374 | +} from "./oauth-gc"; | |
| 375 | +export { | |
| 376 | + oauthClientGcSqliteMigration, | |
| 377 | + runSqliteOAuthClientGcMigration, | |
| 378 | +} from "./sqlite-oauth-client-gc-migration"; | |
| 366 | 379 | export { |
| 367 | 380 | authToolFailure, |
| 368 | 381 | type AuthToolFailureCode, |
| @@ -64,7 +64,15 @@ export interface OAuthClient { · 63 unmodified lines | ||
| 64 | 64 | } |
| 65 | 65 | |
| 66 | 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 | 77 | readonly kind: "dynamic_client_registration"; |
| 70 | 78 | readonly integration?: IntegrationSlug | null; |
| @@ -72,6 +80,7 @@ export type OAuthClientOrigin = · 1 unmodified line | ||
| 72 | 80 | |
| 73 | 81 | export type CreateOAuthClientInput = OAuthClient & { |
| 74 | 82 | readonly origin?: OAuthClientOrigin; |
| 83 | + readonly originIssuer?: string | null; | |
| 75 | 84 | }; |
| 76 | 85 | |
| 77 | 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 | 145 | export interface OAuthProbeResult { |
| 146 | + /** RFC 8414 authorization-server issuer. Used to key DCR clients per AS. */ | |
| 147 | + readonly issuer?: string | null; | |
| 137 | 148 | readonly authorizationUrl: string; |
| 138 | 149 | readonly tokenUrl: string; |
| 139 | 150 | /** RFC 8707 resource indicator discovered from protected-resource metadata. |
| @@ -157,6 +168,8 @@ export interface OAuthProbeResult { · 17 unmodified lines | ||
| 157 | 168 | export interface RegisterDynamicClientInput { |
| 158 | 169 | readonly owner: Owner; |
| 159 | 170 | readonly slug: OAuthClientSlug; |
| 171 | + /** RFC 8414 authorization-server issuer, when discovered before DCR. */ | |
| 172 | + readonly issuer?: string | null; | |
| 160 | 173 | /** RFC 7591 registration endpoint advertised by the authorization server. */ |
| 161 | 174 | readonly registrationEndpoint: string; |
| 162 | 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 | 91 | tokenUrl: "https://acme.test/token", |
| 92 | 92 | resource: null, |
| 93 | 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 | 98 | expect(user!.owner).toBe("user"); |
| 97 | 99 | expect(user!.grant).toBe("client_credentials"); |
| @@ -25,6 +25,18 @@ const TEMPLATE = AuthTemplateSlug.make("oauth"); · 24 unmodified lines | ||
| 25 | 25 | const CLIENT = OAuthClientSlug.make("acme-dcr"); |
| 26 | 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 | 40 | const oauthPlugin = definePlugin(() => ({ |
| 29 | 41 | id: "acme" as const, |
| 30 | 42 | storage: () => ({}), |
| @@ -56,6 +68,7 @@ describe("oauth.registerDynamicClient", () => { · 25 unmodified lines | ||
| 56 | 68 | // Probe surfaces the registration endpoint + advertised auth methods so |
| 57 | 69 | // the caller knows a public client is allowed. |
| 58 | 70 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 71 | + expect(probe.issuer).toBe(server.issuerUrl); | |
| 59 | 72 | expect(probe.registrationEndpoint).toBe(server.registrationEndpoint); |
| 60 | 73 | expect(probe.tokenEndpointAuthMethodsSupported).toContain("none"); |
| 61 | 74 | expect(probe.resource).toBe(server.mcpResourceUrl); |
| @@ -64,6 +77,7 @@ describe("oauth.registerDynamicClient", () => { · 2 unmodified lines | ||
| 64 | 77 | const slug = yield* executor.oauth.registerDynamicClient({ |
| 65 | 78 | owner: "org", |
| 66 | 79 | slug: CLIENT, |
| 80 | + issuer: probe.issuer, | |
| 67 | 81 | registrationEndpoint: probe.registrationEndpoint!, |
| 68 | 82 | authorizationUrl: probe.authorizationUrl, |
| 69 | 83 | tokenUrl: probe.tokenUrl, |
| @@ -74,12 +88,13 @@ describe("oauth.registerDynamicClient", () => { · 4 unmodified lines | ||
| 74 | 88 | redirectUri: FLOW_REDIRECT_URI, |
| 75 | 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 | 94 | // The minted client appears in listClients with a server-issued |
| 80 | 95 | // client_id and NO secret ever projected. |
| 81 | 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 | 98 | expect(minted).toBeDefined(); |
| 84 | 99 | expect(minted!.owner).toBe("org"); |
| 85 | 100 | expect(minted!.grant).toBe("authorization_code"); |
| @@ -98,7 +113,7 @@ describe("oauth.registerDynamicClient", () => { · 12 unmodified lines | ||
| 98 | 113 | // PKCE flow with NO client_secret on the token exchange. |
| 99 | 114 | const started = yield* executor.oauth.start({ |
| 100 | 115 | owner: "org", |
| 101 | − client: CLIENT, | |
| 116 | + client: expectedSlug, | |
| 102 | 117 | clientOwner: "org", |
| 103 | 118 | name: ConnectionName.make("main"), |
| 104 | 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 | 530 | // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only |
| 149 | 531 | // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN |
| 150 | 532 | // origin trips `invalid_redirect_uri`. The failure must explain the loopback |
| @@ -70,6 +70,7 @@ import { · 69 unmodified lines | ||
| 70 | 70 | type OAuthEndpointUrlPolicy, |
| 71 | 71 | } from "./oauth-helpers"; |
| 72 | 72 | import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth"; |
| 73 | +import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc"; | |
| 73 | 74 | |
| 74 | 75 | /** Connection-minting input for the OAuth flow — extends a connection create |
| 75 | 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 | 240 | const parseGrant = (grant: unknown): OAuthGrant | null => |
| 240 | 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 | 305 | const parseOAuthClientOrigin = (row: { |
| 243 | 306 | readonly slug?: unknown; |
| 244 | 307 | readonly grant?: unknown; |
| @@ -246,26 +309,27 @@ const parseOAuthClientOrigin = (row: { · 1 unmodified line | ||
| 246 | 309 | readonly origin_kind?: unknown; |
| 247 | 310 | readonly origin_integration?: unknown; |
| 248 | 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 | 316 | return { |
| 251 | − kind: "dynamic_client_registration", | |
| 317 | + kind: "manual", | |
| 252 | 318 | integration: |
| 253 | 319 | row.origin_integration == null |
| 254 | 320 | ? null |
| 255 | 321 | : IntegrationSlug.make(String(row.origin_integration)), |
| 256 | 322 | }; |
| 257 | 323 | } |
| 258 | − const slug = row.slug == null ? "" : String(row.slug); | |
| 259 | − const resource = row.resource == null ? "" : String(row.resource); | |
| 260 | − if ( | |
| 261 | − row.origin_kind == null && | |
| 262 | − row.grant === "authorization_code" && | |
| 263 | − /(^|[-_])mcp($|[-_])/.test(slug) && | |
| 264 | − /(^|\/)mcp($|[/?#])/.test(resource) | |
| 265 | − ) { | |
| 266 | − return { kind: "dynamic_client_registration", integration: null }; | |
| 267 | − } | |
| 268 | − return { kind: "manual" }; | |
| 324 | + // An explicit-origin DCR row carries its requesting integration; a | |
| 325 | + // heuristic-classified legacy row (null origin_kind) has none. | |
| 326 | + return { | |
| 327 | + kind: "dynamic_client_registration", | |
| 328 | + integration: | |
| 329 | + row.origin_kind === "dynamic_client_registration" && row.origin_integration != null | |
| 330 | + ? IntegrationSlug.make(String(row.origin_integration)) | |
| 331 | + : null, | |
| 332 | + }; | |
| 269 | 333 | }; |
| 270 | 334 | |
| 271 | 335 | interface LoadedOAuthClient { |
| @@ -512,11 +576,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 240 unmodified lines | ||
| 512 | 576 | client_secret_item_id: clientSecretItemIdValue, |
| 513 | 577 | resource: input.resource ?? null, |
| 514 | 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 | 583 | origin_integration: |
| 584 | + input.origin?.integration == null ? null : String(input.origin.integration), | |
| 585 | + origin_issuer: | |
| 516 | 586 | input.origin?.kind === "dynamic_client_registration" |
| 517 | − ? input.origin.integration == null | |
| 518 | − ? null | |
| 519 | − : String(input.origin.integration) | |
| 587 | + ? (canonicalIssuerUrl(input.originIssuer) ?? null) | |
| 520 | 588 | : null, |
| 521 | 589 | created_at: now, |
| 522 | 590 | }), |
| @@ -578,10 +646,128 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines | ||
| 578 | 646 | ? "none" |
| 579 | 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 | 762 | const registerDynamicClient = ( |
| 582 | 763 | input: RegisterDynamicClientInput, |
| 583 | 764 | ): Effect.Effect<OAuthClientSlug, OAuthRegisterDynamicError | StorageFailure> => |
| 584 | 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 | 771 | const flowRedirectUri = input.redirectUri ?? redirectUri; |
| 586 | 772 | // DCR registers our callback as the client's redirect_uri — fail loudly |
| 587 | 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 | 819 | // stored client carries no scope set (the integration drives requests). |
| 634 | 820 | yield* createClient({ |
| 635 | 821 | owner: input.owner, |
| 636 | − slug: input.slug, | |
| 822 | + slug, | |
| 637 | 823 | authorizationUrl: input.authorizationUrl, |
| 638 | 824 | tokenUrl: input.tokenUrl, |
| 639 | 825 | resource: input.resource ?? null, |
| @@ -644,8 +830,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 4 unmodified lines | ||
| 644 | 830 | kind: "dynamic_client_registration", |
| 645 | 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 | 1347 | return { |
| 1348 | + issuer: as.metadata.issuer, | |
| 1161 | 1349 | authorizationUrl: as.metadata.authorization_endpoint, |
| 1162 | 1350 | tokenUrl: as.metadata.token_endpoint, |
| 1163 | 1351 | resource: resource?.metadata.resource ?? null, |
| @@ -110,6 +110,7 @@ export { · 109 unmodified lines | ||
| 110 | 110 | type OAuthGrant, |
| 111 | 111 | type OAuthAuthentication, |
| 112 | 112 | type OAuthClient, |
| 113 | + type OAuthClientOrigin, | |
| 113 | 114 | type OAuthClientSummary, |
| 114 | 115 | type CreateOAuthClientInput, |
| 115 | 116 | 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 | 2 | "name": "@executor-js/vite-plugin", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/core/vite-plugin" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -55,20 +55,30 @@ const waitFor = async (predicate: () => boolean): Promise<void> => { · 54 unmodified lines | ||
| 55 | 55 | expect(predicate()).toBe(true); |
| 56 | 56 | }; |
| 57 | 57 | |
| 58 | −const drainResponse = (response: Response): Promise<void> => { | |
| 59 | − return Effect.runPromise( | |
| 58 | +const drainResponse = async (response: Response): Promise<string> => { | |
| 59 | + const decoder = new TextDecoder(); | |
| 60 | + let body = ""; | |
| 61 | + | |
| 62 | + await Effect.runPromise( | |
| 60 | 63 | Effect.ignore( |
| 61 | 64 | Effect.tryPromise({ |
| 62 | 65 | try: () => |
| 63 | 66 | response.body?.pipeTo( |
| 64 | 67 | new WritableStream<Uint8Array>({ |
| 65 | − write: () => {}, | |
| 68 | + close: () => { | |
| 69 | + body += decoder.decode(); | |
| 70 | + }, | |
| 71 | + write: (chunk) => { | |
| 72 | + body += decoder.decode(chunk, { stream: true }); | |
| 73 | + }, | |
| 66 | 74 | }), |
| 67 | 75 | ) ?? Promise.resolve(), |
| 68 | 76 | catch: () => undefined, |
| 69 | 77 | }), |
| 70 | 78 | ), |
| 71 | 79 | ); |
| 80 | + | |
| 81 | + return body; | |
| 72 | 82 | }; |
| 73 | 83 | |
| 74 | 84 | const installStallingTransformStream = () => { |
| @@ -233,14 +243,19 @@ const rotationLogs = (logs: ReadonlyArray<string>): ReadonlyArray<RotationLog> = · 158 unmodified lines | ||
| 233 | 243 | |
| 234 | 244 | describe("agents SSE max-age rotation", () => { |
| 235 | 245 | let errorLogs: string[] = []; |
| 246 | + let infoLogs: string[] = []; | |
| 236 | 247 | |
| 237 | 248 | beforeEach(() => { |
| 238 | 249 | errorLogs = []; |
| 250 | + infoLogs = []; | |
| 239 | 251 | vi.useFakeTimers(); |
| 240 | 252 | vi.setSystemTime(0); |
| 241 | 253 | vi.spyOn(console, "error").mockImplementation((line) => { |
| 242 | 254 | errorLogs.push(String(line)); |
| 243 | 255 | }); |
| 256 | + vi.spyOn(console, "log").mockImplementation((line) => { | |
| 257 | + infoLogs.push(String(line)); | |
| 258 | + }); | |
| 244 | 259 | }); |
| 245 | 260 | |
| 246 | 261 | afterEach(() => { |
| @@ -254,17 +269,18 @@ describe("agents SSE max-age rotation", () => { · 7 unmodified lines | ||
| 254 | 269 | const drained = drainResponse(response); |
| 255 | 270 | |
| 256 | 271 | await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); |
| 257 | − await waitFor(() => ws.closeCode === 1013); | |
| 272 | + await waitFor(() => ws.closeCode === 1000); | |
| 258 | 273 | |
| 259 | − expect(ws.closeReason).toBe("SSE client not draining"); | |
| 260 | − const [rotationLog] = rotationLogs(errorLogs); | |
| 274 | + expect(ws.closeReason).toBe("sse_max_age_rotation"); | |
| 275 | + const [rotationLog] = rotationLogs(infoLogs); | |
| 261 | 276 | expect(rotationLog?.event).toBe("sse_max_age_close"); |
| 262 | 277 | expect(rotationLog?.ageMs).toBeGreaterThan(MAX_SSE_AGE_MS); |
| 263 | 278 | expect(rotationLog?.ageMs).toBeLessThanOrEqual(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); |
| 264 | 279 | expect(rotationLog?.pendingBytes).toBeGreaterThanOrEqual(0); |
| 280 | + expect(errorLogs).toEqual([]); | |
| 265 | 281 | expect(vi.getTimerCount()).toBe(0); |
| 266 | 282 | |
| 267 | − await drained; | |
| 283 | + await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n"); | |
| 268 | 284 | }); |
| 269 | 285 | |
| 270 | 286 | it("does not rotate an in-flight POST response past max age", async () => { |
| @@ -277,7 +293,7 @@ describe("agents SSE max-age rotation", () => { · 6 unmodified lines | ||
| 277 | 293 | |
| 278 | 294 | expect(ws.closeCode).toBeUndefined(); |
| 279 | 295 | expect(ws.closeReason).toBeUndefined(); |
| 280 | − expect(rotationLogs(errorLogs)).toEqual([]); | |
| 296 | + expect(rotationLogs(infoLogs)).toEqual([]); | |
| 281 | 297 | |
| 282 | 298 | emitAgentEvent( |
| 283 | 299 | ws, |
| @@ -299,7 +315,7 @@ describe("agents SSE max-age rotation", () => { · 15 unmodified lines | ||
| 299 | 315 | await flushMicrotasks(); |
| 300 | 316 | |
| 301 | 317 | expect(ws.closeCode).toBeUndefined(); |
| 302 | − expect(rotationLogs(errorLogs)).toEqual([]); | |
| 318 | + expect(rotationLogs(infoLogs)).toEqual([]); | |
| 303 | 319 | |
| 304 | 320 | emitClose(ws); |
| 305 | 321 | await drained; |
| @@ -325,7 +341,7 @@ describe("agents SSE max-age rotation", () => { · 19 unmodified lines | ||
| 325 | 341 | expect(ws.closeCode).toBe(1013); |
| 326 | 342 | expect(ws.closeReason).toBe("SSE client not draining"); |
| 327 | 343 | expect(transform.abortReason()).toBeInstanceOf(Error); |
| 328 | − expect(rotationLogs(errorLogs)).toEqual([]); | |
| 344 | + expect(rotationLogs(infoLogs)).toEqual([]); | |
| 329 | 345 | expect(vi.getTimerCount()).toBe(0); |
| 330 | 346 | }); |
| 331 | 347 | |
| @@ -338,7 +354,7 @@ describe("agents SSE max-age rotation", () => { · 6 unmodified lines | ||
| 338 | 354 | await drained; |
| 339 | 355 | |
| 340 | 356 | expect(ws.closeCode).toBeUndefined(); |
| 341 | − expect(rotationLogs(errorLogs)).toEqual([]); | |
| 357 | + expect(rotationLogs(infoLogs)).toEqual([]); | |
| 342 | 358 | expect(vi.getTimerCount()).toBe(0); |
| 343 | 359 | }); |
| 344 | 360 | }); |
| @@ -37,7 +37,7 @@ The runtime is passed a `SandboxToolInvoker` that bridges sandbox-side tool call · 36 unmodified lines | ||
| 37 | 37 | |
| 38 | 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 | 42 | ## License |
| 43 | 43 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/codemode-core", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/kernel/core" |
| 13 | 13 | }, |
| 14 | 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 | 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 | 57 | ## License |
| 58 | 58 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/runtime-quickjs", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/kernel/runtime-quickjs" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-desktop-settings", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/desktop-settings" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-example", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/example" |
| 13 | 13 | }, |
| 14 | 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 | 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 | 57 | ## License |
| 58 | 58 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-file-secrets", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/file-secrets" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-google", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/google" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -75,7 +75,7 @@ import { graphqlPlugin } from "@executor-js/plugin-graphql/core"; · 74 unmodified lines | ||
| 75 | 75 | |
| 76 | 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 | 80 | ## License |
| 81 | 81 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-graphql", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/graphql" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -52,7 +52,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain/core"; · 51 unmodified lines | ||
| 52 | 52 | |
| 53 | 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 | 57 | ## License |
| 58 | 58 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-keychain", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/keychain" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -60,7 +60,7 @@ import { mcpPlugin } from "@executor-js/plugin-mcp/core"; · 59 unmodified lines | ||
| 60 | 60 | |
| 61 | 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 | 65 | ## License |
| 66 | 66 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-mcp", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/mcp" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-microsoft", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/microsoft" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -72,7 +72,7 @@ import { onepasswordPlugin } from "@executor-js/plugin-onepassword/core"; · 71 unmodified lines | ||
| 72 | 72 | |
| 73 | 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 | 77 | ## License |
| 78 | 78 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-onepassword", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/onepassword" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -76,7 +76,7 @@ import { openApiPlugin } from "@executor-js/plugin-openapi/core"; · 75 unmodified lines | ||
| 76 | 76 | |
| 77 | 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 | 81 | ## License |
| 82 | 82 | |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-openapi", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/openapi" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-toolkits", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/toolkits" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@executor-js/plugin-workos-vault", |
| 3 | 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 | 5 | "bugs": { |
| 6 | − "url": "https://github.com/RhysSullivan/executor/issues" | |
| 6 | + "url": "https://github.com/UsefulSoftwareCo/executor/issues" | |
| 7 | 7 | }, |
| 8 | 8 | "license": "MIT", |
| 9 | 9 | "repository": { |
| 10 | 10 | "type": "git", |
| 11 | − "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 11 | + "url": "git+https://github.com/UsefulSoftwareCo/executor.git", | |
| 12 | 12 | "directory": "packages/plugins/workos-vault" |
| 13 | 13 | }, |
| 14 | 14 | "files": [ |
| @@ -460,6 +460,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 459 unmodified lines | ||
| 460 | 460 | readonly grant: OAuthGrant; |
| 461 | 461 | readonly clientId: string; |
| 462 | 462 | readonly resource?: string | null; |
| 463 | + readonly originIntegration?: IntegrationSlug | null; | |
| 463 | 464 | }; |
| 464 | 465 | }, |
| 465 | 466 | ) => |
| @@ -472,7 +473,9 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 6 unmodified lines | ||
| 472 | 473 | tokenUrl: arg.payload.tokenUrl, |
| 473 | 474 | resource: arg.payload.resource ?? null, |
| 474 | 475 | clientId: arg.payload.clientId, |
| 475 | − origin: { kind: "manual" }, | |
| 476 | + // Mirror the server's stamp so the just-registered app matches its | |
| 477 | + // integration in the picker immediately (before the refetch lands). | |
| 478 | + origin: { kind: "manual", integration: arg.payload.originIntegration ?? null }, | |
| 476 | 479 | }; |
| 477 | 480 | const index = rows.findIndex( |
| 478 | 481 | (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 | 33 | type ProbeResult = { |
| 34 | + readonly issuer?: string | null; | |
| 34 | 35 | readonly authorizationUrl: string; |
| 35 | 36 | readonly tokenUrl: string; |
| 36 | 37 | readonly resource?: string | null; |
| @@ -42,6 +43,7 @@ type ProbeResult = { · 5 unmodified lines | ||
| 42 | 43 | type RegisterArgs = { |
| 43 | 44 | readonly owner: Owner; |
| 44 | 45 | readonly slug: OAuthClientSlug; |
| 46 | + readonly issuer?: string | null; | |
| 45 | 47 | readonly registrationEndpoint: string; |
| 46 | 48 | readonly authorizationUrl: string; |
| 47 | 49 | readonly tokenUrl: string; |
| @@ -306,6 +308,7 @@ describe("runDcrConnect", () => { · 258 unmodified lines | ||
| 306 | 308 | const probe = (_url: string): Promise<ProbeResult | null> => { |
| 307 | 309 | calls.push("probe"); |
| 308 | 310 | return Promise.resolve({ |
| 311 | + issuer: "https://auth.example.com", | |
| 309 | 312 | authorizationUrl: "https://auth.example.com/authorize", |
| 310 | 313 | tokenUrl: "https://auth.example.com/token", |
| 311 | 314 | resource: "https://mcp.example.com/mcp", |
| @@ -330,7 +333,6 @@ describe("runDcrConnect", () => { · 18 unmodified lines | ||
| 330 | 333 | discoveryUrl: "https://mcp.example.com/mcp", |
| 331 | 334 | owner: "user", |
| 332 | 335 | integrationName: "Linear MCP", |
| 333 | − existingSlugs: [], | |
| 334 | 336 | redirectUri: "https://localhost:5394/api/oauth/callback", |
| 335 | 337 | integration: TEST_INTEGRATION, |
| 336 | 338 | }, |
| @@ -342,6 +344,8 @@ describe("runDcrConnect", () => { · 5 unmodified lines | ||
| 342 | 344 | // DCR always mints an authorization-code/PKCE client; callers do not choose |
| 343 | 345 | // a grant here. |
| 344 | 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 | 349 | expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register"); |
| 346 | 350 | expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize"); |
| 347 | 351 | expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token"); |
| @@ -379,7 +383,6 @@ describe("runDcrConnect", () => { · 31 unmodified lines | ||
| 379 | 383 | resourceFallback: "https://mcp.example.com/mcp", |
| 380 | 384 | owner: "user", |
| 381 | 385 | integrationName: "App", |
| 382 | − existingSlugs: [], | |
| 383 | 386 | declaredScopes: ["declared.scope"], |
| 384 | 387 | integration: TEST_INTEGRATION, |
| 385 | 388 | }, |
| @@ -416,7 +419,6 @@ describe("runDcrConnect", () => { · 30 unmodified lines | ||
| 416 | 419 | discoveryUrl: "https://auth.example.com/token", |
| 417 | 420 | owner: "user", |
| 418 | 421 | integrationName: "App", |
| 419 | − existingSlugs: [], | |
| 420 | 422 | integration: TEST_INTEGRATION, |
| 421 | 423 | }, |
| 422 | 424 | ); |
| @@ -448,7 +450,6 @@ describe("runDcrConnect", () => { · 25 unmodified lines | ||
| 448 | 450 | discoveryUrl: "https://mcp.example.com/mcp", |
| 449 | 451 | owner: "user", |
| 450 | 452 | integrationName: "App", |
| 451 | − existingSlugs: [], | |
| 452 | 453 | integration: TEST_INTEGRATION, |
| 453 | 454 | }, |
| 454 | 455 | ); |
| @@ -483,7 +484,6 @@ describe("runDcrConnect", () => { · 28 unmodified lines | ||
| 483 | 484 | discoveryUrl: "https://mcp.example.com/mcp", |
| 484 | 485 | owner: "user", |
| 485 | 486 | integrationName: "App", |
| 486 | − existingSlugs: [], | |
| 487 | 487 | integration: TEST_INTEGRATION, |
| 488 | 488 | }, |
| 489 | 489 | ); |
| @@ -513,7 +513,6 @@ describe("runDcrConnect", () => { · 23 unmodified lines | ||
| 513 | 513 | discoveryUrl: "https://mcp.example.com/mcp", |
| 514 | 514 | owner: "user", |
| 515 | 515 | integrationName: "App", |
| 516 | − existingSlugs: [], | |
| 517 | 516 | integration: TEST_INTEGRATION, |
| 518 | 517 | }, |
| 519 | 518 | ); |
| @@ -552,7 +551,6 @@ describe("runDcrConnect", () => { · 32 unmodified lines | ||
| 552 | 551 | discoveryUrl: "https://mcp.example.com/mcp", |
| 553 | 552 | owner: "user", |
| 554 | 553 | integrationName: "App", |
| 555 | − existingSlugs: [], | |
| 556 | 554 | integration: TEST_INTEGRATION, |
| 557 | 555 | }, |
| 558 | 556 | ); |
| @@ -570,4 +568,37 @@ describe("runDcrConnect", () => { · 11 unmodified lines | ||
| 570 | 568 | }); |
| 571 | 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 | }); |
| @@ -47,6 +47,8 @@ import { · 46 unmodified lines | ||
| 47 | 47 | import { |
| 48 | 48 | clientDisplayName, |
| 49 | 49 | clientHost, |
| 50 | + optimisticDcrClientSlug, | |
| 51 | + selectDcrClientsForIntegration, | |
| 50 | 52 | uniqueClientSlug, |
| 51 | 53 | useOAuthClientsForIntegration, |
| 52 | 54 | type OAuthClientOption, |
| @@ -64,13 +66,14 @@ import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; · 11 unmodified lines | ||
| 64 | 66 | import { connectionIdentifier } from "../lib/connection-name"; |
| 65 | 67 | import { Badge } from "./badge"; |
| 66 | 68 | import { Button } from "./button"; |
| 69 | +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible"; | |
| 67 | 70 | import { |
| 68 | 71 | DropdownMenu, |
| 69 | 72 | DropdownMenuContent, |
| 70 | 73 | DropdownMenuItem, |
| 71 | 74 | DropdownMenuTrigger, |
| 72 | 75 | } from "./dropdown-menu"; |
| 73 | −import { PlusIcon, XIcon } from "lucide-react"; | |
| 76 | +import { ChevronDown, PlusIcon, XIcon } from "lucide-react"; | |
| 74 | 77 | import { |
| 75 | 78 | Dialog, |
| 76 | 79 | DialogContent, |
| @@ -613,6 +616,7 @@ export async function runCimdConnect( · 536 unmodified lines | ||
| 613 | 616 | |
| 614 | 617 | /** Discovery result from the probe step (subset of the `probeOAuth` response). */ |
| 615 | 618 | type DcrProbeResult = { |
| 619 | + readonly issuer?: string | null; | |
| 616 | 620 | readonly authorizationUrl: string; |
| 617 | 621 | readonly tokenUrl: string; |
| 618 | 622 | readonly resource?: string | null; |
| @@ -624,6 +628,7 @@ type DcrProbeResult = { · 5 unmodified lines | ||
| 624 | 628 | type DcrRegisterArgs = { |
| 625 | 629 | readonly owner: Owner; |
| 626 | 630 | readonly slug: OAuthClientSlug; |
| 631 | + readonly issuer?: string | null; | |
| 627 | 632 | readonly registrationEndpoint: string; |
| 628 | 633 | readonly authorizationUrl: string; |
| 629 | 634 | readonly tokenUrl: string; |
| @@ -684,8 +689,6 @@ type RunDcrConnectInput = { · 54 unmodified lines | ||
| 684 | 689 | readonly resourceFallback?: string; |
| 685 | 690 | readonly owner: Owner; |
| 686 | 691 | readonly integrationName: string; |
| 687 | − /** The owner's existing client slugs, so the minted slug stays unique. */ | |
| 688 | − readonly existingSlugs: readonly string[]; | |
| 689 | 692 | /** Scopes declared by the integration's method (override the probed ones). */ |
| 690 | 693 | readonly declaredScopes?: readonly string[]; |
| 691 | 694 | /** Browser-facing callback URL registered with DCR when available. */ |
| @@ -718,11 +721,12 @@ export async function runDcrConnect( · 26 unmodified lines | ||
| 718 | 721 | const registrationEndpoint = probe.registrationEndpoint; |
| 719 | 722 | if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe }; |
| 720 | 723 | |
| 721 | − const slug = uniqueClientSlug(input.integrationName, input.existingSlugs); | |
| 724 | + const slug = optimisticDcrClientSlug(probe.issuer ?? registrationEndpoint); | |
| 722 | 725 | const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []); |
| 723 | 726 | const minted = await deps.register({ |
| 724 | 727 | owner: input.owner, |
| 725 | 728 | slug, |
| 729 | + issuer: probe.issuer ?? null, | |
| 726 | 730 | registrationEndpoint, |
| 727 | 731 | authorizationUrl: probe.authorizationUrl, |
| 728 | 732 | tokenUrl: probe.tokenUrl, |
| @@ -898,6 +902,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 169 unmodified lines | ||
| 898 | 902 | // FIX 3 escape hatch: when no registered app matched the integration's |
| 899 | 903 | // endpoints, the unmatched apps are collapsed behind an opt-in expander. |
| 900 | 904 | const [showOtherApps, setShowOtherApps] = useState(false); |
| 905 | + // Auto-registered (DCR) clients are plumbing, not pickable apps: they live in a | |
| 906 | + // collapsed management section at the bottom of the OAuth tab, revealed on | |
| 907 | + // demand so they can be inspected and deleted without cluttering the picker. | |
| 908 | + const [showAutoRegistered, setShowAutoRegistered] = useState(false); | |
| 901 | 909 | // Inline OAuth app management — edit re-opens the registration form for an |
| 902 | 910 | // existing app (upsert by owner+slug); remove confirms before deleting. Both |
| 903 | 911 | // hold the FULL app summary (with endpoints + resource) so the edit prefill |
| @@ -1076,6 +1084,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 172 unmodified lines | ||
| 1076 | 1084 | // unconditionally; in DCR mode the result is ignored until/unless we fall back. |
| 1077 | 1085 | const { |
| 1078 | 1086 | clients: oauthApps, |
| 1087 | + nearMatches: oauthNearApps, | |
| 1079 | 1088 | otherClients: oauthOtherApps, |
| 1080 | 1089 | loading: oauthLoading, |
| 1081 | 1090 | endpointMatched: oauthEndpointMatched, |
| @@ -1088,8 +1097,29 @@ function AddAccountModalView(props: AddAccountModalProps) { · 6 unmodified lines | ||
| 1088 | 1097 | // unrelated provider's app. |
| 1089 | 1098 | tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, |
| 1090 | 1099 | authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, |
| 1100 | + // Recorded intent: a manual app registered from THIS integration's dialog is | |
| 1101 | + // a tier-1 match regardless of host. | |
| 1102 | + integration, | |
| 1091 | 1103 | requireEndpointMatch: isDcr, |
| 1092 | 1104 | }); |
| 1105 | + // Auto-registered (DCR) clients for THIS integration. Excluded from the picker | |
| 1106 | + // above, but surfaced in a collapsed management section so they stay | |
| 1107 | + // deletable. Reads the same optimistic list the picker does. | |
| 1108 | + const dcrClients = useMemo( | |
| 1109 | + () => | |
| 1110 | + selectDcrClientsForIntegration(clientSummaries as readonly OAuthClientOption[], { | |
| 1111 | + integration, | |
| 1112 | + tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, | |
| 1113 | + authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, | |
| 1114 | + }), | |
| 1115 | + [ | |
| 1116 | + clientSummaries, | |
| 1117 | + integration, | |
| 1118 | + method?.oauth?.tokenUrl, | |
| 1119 | + method?.oauth?.authorizationUrl, | |
| 1120 | + oauthFallbackProbe, | |
| 1121 | + ], | |
| 1122 | + ); | |
| 1093 | 1123 | const oauthPopup = useOAuthPopupFlow({ |
| 1094 | 1124 | popupName: "add-account-oauth", |
| 1095 | 1125 | detectPopupClosed: false, |
| @@ -1106,8 +1136,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines | ||
| 1106 | 1136 | // Editing reuses the registration form (createClient upserts by owner+slug), |
| 1107 | 1137 | // so it occupies the same full-bleed sub-view as registering. |
| 1108 | 1138 | const oauthEditing = isOAuth && editingClient !== null; |
| 1139 | + // Resolve the picked slug across every PICKABLE tier: exact matches, the | |
| 1140 | + // subdued near-match tier, and the unrelated escape-hatch apps. All three are | |
| 1141 | + // selectable (only the default auto-selection is restricted to tier 1), so the | |
| 1142 | + // connect button must resolve a client from any of them. DCR clients are not | |
| 1143 | + // in these lists, so they can never be chosen as the connect app. | |
| 1109 | 1144 | const chosenClient: OAuthClientOption | null = |
| 1110 | − oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null; | |
| 1145 | + [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find( | |
| 1146 | + (c: OAuthClientOption) => String(c.slug) === selectedApp, | |
| 1147 | + ) ?? null; | |
| 1111 | 1148 | const oauthBusy = ccBusy || oauthPopup.busy; |
| 1112 | 1149 | const cimdConnecting = cimdBusy || oauthPopup.busy; |
| 1113 | 1150 | const dcrConnecting = dcrBusy || oauthPopup.busy; |
| @@ -1444,6 +1481,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 330 unmodified lines | ||
| 1444 | 1481 | payload: { |
| 1445 | 1482 | owner: args.owner, |
| 1446 | 1483 | slug: args.slug, |
| 1484 | + issuer: args.issuer ?? null, | |
| 1447 | 1485 | registrationEndpoint: args.registrationEndpoint, |
| 1448 | 1486 | authorizationUrl: args.authorizationUrl, |
| 1449 | 1487 | tokenUrl: args.tokenUrl, |
| @@ -1491,9 +1529,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 41 unmodified lines | ||
| 1491 | 1529 | resourceFallback: method.oauth?.discoveryUrl, |
| 1492 | 1530 | owner: dcrOwner, |
| 1493 | 1531 | integrationName, |
| 1494 | − existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) => | |
| 1495 | − String(app.slug), | |
| 1496 | − ), | |
| 1532 | + // DCR slugs are server-keyed (Part A): the connect path no longer depends | |
| 1533 | + // on the picker's app list, so it need not be threaded here. | |
| 1497 | 1534 | declaredScopes: method.oauth?.scopes, |
| 1498 | 1535 | redirectUri: oauthCallbackUrl(), |
| 1499 | 1536 | integration, |
| @@ -1553,11 +1590,18 @@ function AddAccountModalView(props: AddAccountModalProps) { · 53 unmodified lines | ||
| 1553 | 1590 | <div className="px-5 py-5"> |
| 1554 | 1591 | <OAuthClientForm |
| 1555 | 1592 | integrationName={integrationName} |
| 1556 | − existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) => | |
| 1557 | − String(app.slug), | |
| 1558 | − )} | |
| 1593 | + existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))} | |
| 1559 | 1594 | fixedSlug={editingClient.slug} |
| 1560 | 1595 | fixedOwner={editingClient.owner} |
| 1596 | + // Preserve the app's already-recorded origin verbatim (only a | |
| 1597 | + // "manual" origin carries a recorded integration; DCR apps | |
| 1598 | + // aren't edited through this form). Editing must never | |
| 1599 | + // re-derive this from the current dialog's integration. | |
| 1600 | + intentIntegration={ | |
| 1601 | + editingClient.origin.kind === "manual" | |
| 1602 | + ? (editingClient.origin.integration ?? null) | |
| 1603 | + : null | |
| 1604 | + } | |
| 1561 | 1605 | prefill={{ |
| 1562 | 1606 | authorizationUrl: editingClient.authorizationUrl, |
| 1563 | 1607 | tokenUrl: editingClient.tokenUrl, |
| @@ -1582,9 +1626,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 18 unmodified lines | ||
| 1582 | 1626 | <div className="px-5 py-5"> |
| 1583 | 1627 | <OAuthClientForm |
| 1584 | 1628 | integrationName={integrationName} |
| 1585 | − existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) => | |
| 1586 | − String(app.slug), | |
| 1587 | − )} | |
| 1629 | + integrationSlug={integration} | |
| 1630 | + existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))} | |
| 1588 | 1631 | autoRegisterRejectedReason={dcrFallbackMessage} |
| 1589 | 1632 | prefill={{ |
| 1590 | 1633 | authorizationUrl: |
| @@ -1602,6 +1645,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 11 unmodified lines | ||
| 1602 | 1645 | null, |
| 1603 | 1646 | scopes: method.oauth?.scopes, |
| 1604 | 1647 | discoveredScopes: oauthFallbackProbe?.scopesSupported, |
| 1648 | + issuer: oauthFallbackProbe?.issuer ?? null, | |
| 1605 | 1649 | registrationEndpoint: |
| 1606 | 1650 | method.oauth?.registrationEndpoint ?? |
| 1607 | 1651 | oauthFallbackProbe?.registrationEndpoint ?? |
| @@ -1875,6 +1919,107 @@ function AddAccountModalView(props: AddAccountModalProps) { · 267 unmodified lines | ||
| 1875 | 1919 | </Button> |
| 1876 | 1920 | </RadioGroup> |
| 1877 | 1921 | )} |
| 1922 | + | |
| 1923 | + {/* Tier 2 — apps that only match by root domain (a | |
| 1924 | + near-miss, e.g. the same provider's MCP app for a REST | |
| 1925 | + integration). Visually separated and subdued so they are | |
| 1926 | + never mistaken for the integration's own app, but still | |
| 1927 | + selectable as an escape hatch. Shown alongside tier 1, not | |
| 1928 | + mixed into it. */} | |
| 1929 | + {oauthNearApps.length > 0 && ( | |
| 1930 | + <div className="space-y-2 border-t border-border/60 pt-3"> | |
| 1931 | + <p className="text-xs font-medium text-muted-foreground"> | |
| 1932 | + Other apps on this provider | |
| 1933 | + </p> | |
| 1934 | + <p className="text-xs text-muted-foreground"> | |
| 1935 | + These share {integrationName}'s domain but weren't | |
| 1936 | + registered for it. Pick one only if you know it applies. | |
| 1937 | + </p> | |
| 1938 | + <RadioGroup | |
| 1939 | + value={selectedApp} | |
| 1940 | + onValueChange={setPickedApp} | |
| 1941 | + className="gap-2 pt-1" | |
| 1942 | + > | |
| 1943 | + {oauthNearApps.map((app: OAuthClientOption) => ( | |
| 1944 | + <OAuthAppRadioRow | |
| 1945 | + key={String(app.slug)} | |
| 1946 | + app={app} | |
| 1947 | + idPrefix="near-app" | |
| 1948 | + variant="other" | |
| 1949 | + showOwnerLabel={ownerDisplay.showOwnerLabels} | |
| 1950 | + onManage={manageHandlersFor(app)} | |
| 1951 | + /> | |
| 1952 | + ))} | |
| 1953 | + </RadioGroup> | |
| 1954 | + </div> | |
| 1955 | + )} | |
| 1956 | + | |
| 1957 | + {/* Auto-registered (DCR) clients: plumbing, never | |
| 1958 | + pickable. Kept here, collapsed, so they can be reviewed | |
| 1959 | + and deleted without cluttering the picker. */} | |
| 1960 | + {dcrClients.length > 0 && ( | |
| 1961 | + <div className="border-t border-border/60 pt-3"> | |
| 1962 | + <Collapsible | |
| 1963 | + open={showAutoRegistered} | |
| 1964 | + onOpenChange={setShowAutoRegistered} | |
| 1965 | + > | |
| 1966 | + <CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"> | |
| 1967 | + <span>Auto-registered clients ({dcrClients.length})</span> | |
| 1968 | + <ChevronDown | |
| 1969 | + className={cn( | |
| 1970 | + "size-3.5 transition-transform", | |
| 1971 | + showAutoRegistered && "rotate-180", | |
| 1972 | + )} | |
| 1973 | + aria-hidden="true" | |
| 1974 | + /> | |
| 1975 | + </CollapsibleTrigger> | |
| 1976 | + <CollapsibleContent> | |
| 1977 | + <div className="space-y-2 pt-2"> | |
| 1978 | + <p className="text-xs text-muted-foreground"> | |
| 1979 | + Created automatically when connecting. Reused across | |
| 1980 | + connections, not selectable as your app. | |
| 1981 | + </p> | |
| 1982 | + {dcrClients.map((app: OAuthClientOption) => ( | |
| 1983 | + <div | |
| 1984 | + key={String(app.slug)} | |
| 1985 | + className="flex items-center gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5" | |
| 1986 | + > | |
| 1987 | + <span className="min-w-0 flex-1"> | |
| 1988 | + <span className="block text-sm font-medium"> | |
| 1989 | + {clientDisplayName(String(app.slug))} | |
| 1990 | + </span> | |
| 1991 | + <span className="block truncate text-xs text-muted-foreground"> | |
| 1992 | + {clientHost(app.tokenUrl)} | |
| 1993 | + </span> | |
| 1994 | + </span> | |
| 1995 | + {ownerDisplay.showOwnerLabels ? ( | |
| 1996 | + <Badge variant="outline"> | |
| 1997 | + {ownerLabel(app.owner)} | |
| 1998 | + </Badge> | |
| 1999 | + ) : null} | |
| 2000 | + <Button | |
| 2001 | + type="button" | |
| 2002 | + variant="ghost" | |
| 2003 | + size="sm" | |
| 2004 | + className="shrink-0 text-destructive hover:text-destructive" | |
| 2005 | + onClick={() => { | |
| 2006 | + const summary = clientSummaries.find( | |
| 2007 | + (c: OAuthClientSummary) => | |
| 2008 | + c.owner === app.owner && | |
| 2009 | + String(c.slug) === String(app.slug), | |
| 2010 | + ); | |
| 2011 | + if (summary) setRemovingClient(summary); | |
| 2012 | + }} | |
| 2013 | + > | |
| 2014 | + Remove | |
| 2015 | + </Button> | |
| 2016 | + </div> | |
| 2017 | + ))} | |
| 2018 | + </div> | |
| 2019 | + </CollapsibleContent> | |
| 2020 | + </Collapsible> | |
| 2021 | + </div> | |
| 2022 | + )} | |
| 1878 | 2023 | </div> |
| 1879 | 2024 | ) |
| 1880 | 2025 | ) : ( |
| @@ -1,6 +1,11 @@ | ||
| 1 | 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 | 10 | const validBase = { |
| 6 | 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 | 67 | describe("canSubmitOAuthClientForm", () => { |
| 40 | 68 | it("allows authorization-code public clients with no client secret", () => { |
| 41 | 69 | expect( |
| @@ -1,7 +1,12 @@ | ||
| 1 | 1 | import { useMemo, useState } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | 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 | 10 | import { toast } from "sonner"; |
| 6 | 11 | |
| 7 | 12 | import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms"; |
| @@ -9,7 +14,7 @@ import { ownerLabelForHost } from "../api/owner-display"; · 1 unmodified line | ||
| 9 | 14 | import { trackEvent } from "../api/analytics"; |
| 10 | 15 | import { useOrganizationId } from "../api/organization-context"; |
| 11 | 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 | 18 | import { oauthCallbackUrl } from "../plugins/oauth-sign-in"; |
| 14 | 19 | import { |
| 15 | 20 | ConnectionOwnerDropdown, |
| @@ -36,6 +41,7 @@ import { RadioGroup, RadioGroupItem } from "./radio-group"; · 20 unmodified lines | ||
| 36 | 41 | // --------------------------------------------------------------------------- |
| 37 | 42 | |
| 38 | 43 | export interface OAuthClientFormPrefill { |
| 44 | + readonly issuer?: string | null; | |
| 39 | 45 | readonly authorizationUrl?: string; |
| 40 | 46 | readonly tokenUrl?: string; |
| 41 | 47 | readonly resource?: string | null; |
| @@ -70,6 +76,19 @@ export const registrationScopes = ( · 28 unmodified lines | ||
| 70 | 76 | discoveredScopes: readonly string[], |
| 71 | 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 | 92 | export const canSubmitOAuthClientForm = (input: { |
| 74 | 93 | readonly submitting: boolean; |
| 75 | 94 | readonly name: string; |
| @@ -89,6 +108,19 @@ export const canSubmitOAuthClientForm = (input: { · 13 unmodified lines | ||
| 89 | 108 | export function OAuthClientForm(props: { |
| 90 | 109 | /** Human label for the integration this app backs (used in toasts + default name). */ |
| 91 | 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 | 124 | /** Existing client slugs, so the generated slug stays unique across apps. */ |
| 93 | 125 | readonly existingSlugs: readonly string[]; |
| 94 | 126 | /** Endpoints/scopes declared by the integration's OAuth method. */ |
| @@ -112,6 +144,8 @@ export function OAuthClientForm(props: { · 17 unmodified lines | ||
| 112 | 144 | }) { |
| 113 | 145 | const { |
| 114 | 146 | integrationName, |
| 147 | + integrationSlug, | |
| 148 | + intentIntegration, | |
| 115 | 149 | existingSlugs, |
| 116 | 150 | prefill, |
| 117 | 151 | fixedSlug, |
| @@ -147,6 +181,7 @@ export function OAuthClientForm(props: { · 29 unmodified lines | ||
| 147 | 181 | const [clientId, setClientId] = useState(prefill?.clientId ?? ""); |
| 148 | 182 | const [clientSecret, setClientSecret] = useState(""); |
| 149 | 183 | const [issuerUrl, setIssuerUrl] = useState(""); |
| 184 | + const [discoveredIssuer, setDiscoveredIssuer] = useState<string | null>(prefill?.issuer ?? null); | |
| 150 | 185 | const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? ""); |
| 151 | 186 | const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? ""); |
| 152 | 187 | const [resource, setResource] = useState(prefill?.resource ?? null); |
| @@ -226,6 +261,7 @@ export function OAuthClientForm(props: { · 73 unmodified lines | ||
| 226 | 261 | return; |
| 227 | 262 | } |
| 228 | 263 | const result = exit.value; |
| 264 | + setDiscoveredIssuer(result.issuer ?? null); | |
| 229 | 265 | setAuthorizationUrl(result.authorizationUrl); |
| 230 | 266 | setTokenUrl(result.tokenUrl); |
| 231 | 267 | setResource(result.resource ?? null); |
| @@ -247,11 +283,13 @@ export function OAuthClientForm(props: { · 15 unmodified lines | ||
| 247 | 283 | const handleRegisterDynamic = async () => { |
| 248 | 284 | if (!canRegisterDynamic || registering) return; |
| 249 | 285 | setRegistering(true); |
| 250 | − const slug = fixedSlug ?? uniqueClientSlug(name, existingSlugs); | |
| 286 | + const slug = | |
| 287 | + fixedSlug ?? optimisticDcrClientSlug(discoveredIssuer ?? registrationEndpoint.trim()); | |
| 251 | 288 | const exit = await doRegisterDynamic({ |
| 252 | 289 | payload: { |
| 253 | 290 | owner, |
| 254 | 291 | slug, |
| 292 | + issuer: discoveredIssuer, | |
| 255 | 293 | registrationEndpoint: registrationEndpoint.trim(), |
| 256 | 294 | authorizationUrl: authorizationUrl.trim(), |
| 257 | 295 | tokenUrl: tokenUrl.trim(), |
| @@ -273,7 +311,7 @@ export function OAuthClientForm(props: { · 15 unmodified lines | ||
| 273 | 311 | } |
| 274 | 312 | trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true }); |
| 275 | 313 | toast.success(`Registered ${integrationName} OAuth app`); |
| 276 | − onCreated({ owner, slug }); | |
| 314 | + onCreated({ owner, slug: exit.value.client }); | |
| 277 | 315 | }; |
| 278 | 316 | |
| 279 | 317 | const handleSubmit = async () => { |
| @@ -290,6 +328,10 @@ export function OAuthClientForm(props: { · 10 unmodified lines | ||
| 290 | 328 | clientId: clientId.trim(), |
| 291 | 329 | clientSecret: clientSecret.trim(), |
| 292 | 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 | 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 | 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 | 5 | import { |
| 6 | + optimisticDcrClientSlug, | |
| 5 | 7 | selectClientsForEndpoints, |
| 8 | + selectDcrClientsForIntegration, | |
| 6 | 9 | uniqueClientSlug, |
| 7 | 10 | type OAuthClientOption, |
| 8 | 11 | } from "./use-effective-oauth-client"; |
| @@ -13,6 +16,7 @@ const app = ( · 4 unmodified lines | ||
| 13 | 16 | readonly owner?: Owner; |
| 14 | 17 | readonly authorizationUrl: string; |
| 15 | 18 | readonly tokenUrl: string; |
| 19 | + readonly origin?: OAuthClientOrigin; | |
| 16 | 20 | }, |
| 17 | 21 | ): OAuthClientOption => ({ |
| 18 | 22 | owner: opts.owner ?? "user", |
| @@ -21,6 +25,7 @@ const app = ( · 2 unmodified lines | ||
| 21 | 25 | authorizationUrl: opts.authorizationUrl, |
| 22 | 26 | tokenUrl: opts.tokenUrl, |
| 23 | 27 | clientId: "client-id", |
| 28 | + origin: opts.origin ?? { kind: "manual" }, | |
| 24 | 29 | }); |
| 25 | 30 | |
| 26 | 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 | 118 | it("treats every app as usable when no endpoint is declared", () => { |
| 93 | 119 | const result = selectClientsForEndpoints([google, spotify], {}); |
| 94 | 120 | expect(result.endpointMatched).toBe(true); |
| @@ -133,6 +159,161 @@ describe("selectClientsForEndpoints", () => { · 38 unmodified lines | ||
| 133 | 159 | }); |
| 134 | 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 | 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 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 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 | 10 | import { getDomain } from "tldts"; |
| 5 | 11 | |
| 6 | 12 | import { oauthClientsOptimisticAtom } from "../api/atoms"; |
| @@ -9,12 +15,17 @@ import { oauthClientsOptimisticAtom } from "../api/atoms"; · 2 unmodified lines | ||
| 9 | 15 | // OAuth client (registered app) selection for an integration's connect flow. |
| 10 | 16 | // |
| 11 | 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 | |
| 13 | −// integration (owner-visible clients whose OAuth endpoints share a registrable | |
| 14 | −// root domain with the integration's declared endpoints when known) and lets the | |
| 15 | −// user pick one or register a new one. User-owned apps are listed before | |
| 16 | −// workspace ones. When NOTHING matches a declared endpoint, the picker shows an | |
| 17 | −// empty state + a "register an app" CTA rather than unrelated providers' apps. | |
| 18 | +// row with its own slug. The connect flow lists the MANUAL apps usable for an | |
| 19 | +// integration and lets the user pick one or register a new one. Matching is by | |
| 20 | +// recorded intent first (an app registered from this integration's dialog, or | |
| 21 | +// whose OAuth endpoint HOST exactly matches the integration's declared host), | |
| 22 | +// then by registrable root domain as a subdued near-miss tier. User-owned 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 | 31 | export interface OAuthClientOption { |
| @@ -24,8 +35,18 @@ export interface OAuthClientOption { · 3 unmodified lines | ||
| 24 | 35 | readonly authorizationUrl: string; |
| 25 | 36 | readonly tokenUrl: string; |
| 26 | 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 | 50 | const hostOf = (url: string): string | undefined => { |
| 30 | 51 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" |
| 31 | 52 | try { |
| @@ -46,35 +67,54 @@ const getRootDomain = (url: string): string | undefined => { · 14 unmodified lines | ||
| 46 | 67 | }; |
| 47 | 68 | |
| 48 | 69 | export interface UseOAuthClientsResult { |
| 49 | − /** Apps usable for this integration, user-owned first. When an endpoint was | |
| 50 | − * declared but nothing matched, this is EMPTY (the unmatched apps move to | |
| 51 | − * `otherClients`). */ | |
| 70 | + /** Tier 1 — apps that match this integration by RECORDED INTENT (registered | |
| 71 | + * from its dialog) or EXACT endpoint host, user-owned first. These are the | |
| 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 | 74 | readonly clients: readonly OAuthClientOption[]; |
| 53 | − /** Unmatched owner-visible apps — surfaced only as an opt-in escape hatch | |
| 54 | − * ("use a different registered app") when no app matched the declared | |
| 55 | − * endpoint. Empty when `endpointMatched` is true. */ | |
| 75 | + /** Tier 2 — apps that only match by registrable root domain (a near-miss: same | |
| 76 | + * provider family, different exact host / not stamped to this integration). | |
| 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 | 84 | readonly otherClients: readonly OAuthClientOption[]; |
| 57 | 85 | /** True until the clients list has loaded at least once. */ |
| 58 | 86 | readonly loading: boolean; |
| 59 | 87 | /** |
| 60 | − * Whether the returned `clients` are matched to the integration's declared | |
| 61 | − * OAuth endpoints (by registrable root domain across authorize + token). | |
| 88 | + * Whether an app is EXACT-matched (tier 1) to the integration. | |
| 62 | 89 | * |
| 63 | 90 | * - `true` — either no endpoint filter was requested (the integration |
| 64 | − * declares no endpoints), or at least one registered app's authorize/token | |
| 65 | − * root domain matched. `clients` are the matched subset. | |
| 66 | − * - `false` — the integration declared endpoint(s) but NO registered app | |
| 67 | − * matched. `clients` is then EMPTY (so the UI shows an empty state + a | |
| 68 | − * register CTA rather than unrelated providers' apps), and the unmatched | |
| 69 | − * apps are surfaced separately in `otherClients` for the opt-in escape hatch. | |
| 91 | + * declares no endpoints), or at least one app matched by recorded intent or | |
| 92 | + * exact endpoint host. `clients` is that tier-1 subset. | |
| 93 | + * - `false` — the integration declared endpoint(s) but no app matched exactly. | |
| 94 | + * `clients` is EMPTY (the UI shows an empty state + a register CTA). Any | |
| 95 | + * root-domain near-misses are still offered via `nearMatches`; truly | |
| 96 | + * unrelated apps via `otherClients`. | |
| 70 | 97 | */ |
| 71 | 98 | readonly endpointMatched: boolean; |
| 72 | 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 | 101 | * once loaded. */ |
| 75 | 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 | 118 | /** Sort apps user-owned first (so the user's own apps surface before shared |
| 79 | 119 | * workspace apps). */ |
| 80 | 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 | |
| 87 | − * match the integration's declared OAuth endpoints and the ones that don't. | |
| 126 | + * Pure matcher (no React/atoms) — split owner-visible apps into three honest | |
| 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 | |
| 90 | − * declares a token endpoint, an app must match by token endpoint root; the token | |
| 91 | − * endpoint is what the SDK will call during code exchange/refresh and avoids | |
| 92 | − * authorize-root coincidences. When only an authorization endpoint is declared, | |
| 93 | − * the authorize root is used as the fallback compatibility signal. Unrelated | |
| 94 | − * providers (different root) never match. | |
| 133 | + * Then the remaining MANUAL apps are graded: | |
| 134 | + * - `matched` (tier 1): the app was registered from THIS integration's dialog | |
| 135 | + * (`origin.integration === integration`, recorded intent), OR its OAuth | |
| 136 | + * endpoint HOST exactly matches the integration's declared host (token host | |
| 137 | + * when a token endpoint is declared, else the authorize host). This is the | |
| 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 | 149 | export function selectClientsForEndpoints( |
| 99 | 150 | all: readonly OAuthClientOption[], |
| 100 | 151 | endpoints: { |
| 101 | 152 | readonly tokenUrl?: string; |
| 102 | 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 | 157 | /** When set, an integration that targets a SPECIFIC server (MCP, whose |
| 104 | 158 | * endpoints are discovered at connect) must match by endpoint — absent |
| 105 | 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 | 164 | readonly matched: readonly OAuthClientOption[]; |
| 165 | + readonly nearMatches: readonly OAuthClientOption[]; | |
| 111 | 166 | readonly unmatched: readonly OAuthClientOption[]; |
| 112 | 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 | 183 | const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined; |
| 115 | 184 | const wantedAuthorizationRoot = endpoints.authorizationUrl |
| 116 | 185 | ? getRootDomain(endpoints.authorizationUrl) |
| 117 | 186 | : undefined; |
| 187 | + | |
| 118 | 188 | // No declared endpoints. A server-targeting integration must NOT match every |
| 119 | 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 | 191 | if (!wantedTokenRoot && !wantedAuthorizationRoot) { |
| 122 | 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 | 212 | const matched: OAuthClientOption[] = []; |
| 213 | + const nearMatches: OAuthClientOption[] = []; | |
| 128 | 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 | 218 | const appTokenRoot = getRootDomain(app.tokenUrl); |
| 131 | 219 | const appAuthorizationRoot = getRootDomain(app.authorizationUrl); |
| 132 | − const fits = wantedTokenRoot | |
| 133 | − ? appTokenRoot === wantedTokenRoot | |
| 134 | − : appAuthorizationRoot === wantedAuthorizationRoot || | |
| 135 | − appTokenRoot === wantedAuthorizationRoot; | |
| 136 | − if (fits) matched.push(app); | |
| 220 | + // Exact HOST match against the declared endpoint (token host first, since the | |
| 221 | + // token endpoint is what the SDK actually calls; authorize host as fallback | |
| 222 | + // when no token endpoint was declared). | |
| 223 | + const exactHostMatch = wantedTokenHost | |
| 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 | 235 | else unmatched.push(app); |
| 138 | 236 | } |
| 139 | 237 | return { |
| 140 | 238 | matched: sortUserFirst(matched), |
| 239 | + nearMatches: sortUserFirst(nearMatches), | |
| 141 | 240 | unmatched: sortUserFirst(unmatched), |
| 142 | 241 | endpointMatched: matched.length > 0, |
| 143 | 242 | }; |
| @@ -146,6 +245,7 @@ export function selectClientsForEndpoints( · 2 unmodified lines | ||
| 146 | 245 | export function useOAuthClientsForIntegration(opts: { |
| 147 | 246 | readonly tokenUrl?: string; |
| 148 | 247 | readonly authorizationUrl?: string; |
| 248 | + readonly integration?: IntegrationSlug; | |
| 149 | 249 | readonly requireEndpointMatch?: boolean; |
| 150 | 250 | }): UseOAuthClientsResult { |
| 151 | 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 | 253 | // lands. The modal's management menu reads the same optimistic atom, so the |
| 154 | 254 | // picker rows and their actions stay consistent. |
| 155 | 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 | 276 | return { |
| 158 | 277 | clients: [], |
| 278 | + nearMatches: [], | |
| 159 | 279 | otherClients: [], |
| 160 | 280 | loading: true, |
| 161 | 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[]; | |
| 167 | − const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, { | |
| 168 | − tokenUrl: opts.tokenUrl, | |
| 169 | − authorizationUrl: opts.authorizationUrl, | |
| 170 | − requireEndpointMatch: opts.requireEndpointMatch, | |
| 171 | − }); | |
| 172 | − // EXPLICIT outcome: when at least one app matched (or no endpoint was | |
| 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. | |
| 286 | + const { matched, nearMatches, unmatched, endpointMatched } = selection; | |
| 287 | + // EXPLICIT outcome: `clients` is the tier-1 (exact/intent) subset the picker | |
| 288 | + // defaults into. `nearMatches` (root-domain-only) is always surfaced, but in a | |
| 289 | + // separate subdued section so it is never mistaken for a real match. When an | |
| 290 | + // endpoint was declared but nothing exact-matched, `clients` is EMPTY and a | |
| 291 | + // register CTA is flagged; the unrelated `unmatched` apps stay in `otherClients` | |
| 292 | + // as the last-resort escape hatch. | |
| 177 | 293 | return { |
| 178 | 294 | clients: endpointMatched ? matched : [], |
| 295 | + nearMatches, | |
| 179 | 296 | otherClients: endpointMatched ? [] : unmatched, |
| 180 | 297 | loading: false, |
| 181 | 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 | 346 | const slugifyName = (name: string): string => |
| 187 | 347 | name |
| 188 | 348 | .toLowerCase() |
| @@ -199,6 +359,24 @@ export function uniqueClientSlug(name: string, existing: readonly string[]): OAu · 10 unmodified lines | ||
| 199 | 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 | 380 | /** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */ |
| 203 | 381 | export function clientDisplayName(slug: string): string { |
| 204 | 382 | const text = slug.replace(/[-_]/g, " ").trim(); |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts |
| 2 | −index c8fad44..439da01 100644 | |
| 2 | +index c8fad448e8797b89690a99d93490d1363851b225..439da01e789713d217778e8bdebfa1a9d52a71e2 100644 | |
| 3 | 3 | --- a/dist/mcp/index.d.ts |
| 4 | 4 | +++ b/dist/mcp/index.d.ts |
| 5 | 5 | @@ -29,6 +29,7 @@ import { |
| @@ -19,7 +19,7 @@ index c8fad44..439da01 100644 · 13 unmodified lines | ||
| 19 | 19 | McpAgent, |
| 20 | 20 | type McpAuthContext, |
| 21 | 21 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js |
| 22 | −index 1edcf0c..707f1cf 100644 | |
| 22 | +index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..0ba4f5bff8e6a968aa18c609952e576cedf13d8f 100644 | |
| 23 | 23 | --- a/dist/mcp/index.js |
| 24 | 24 | +++ b/dist/mcp/index.js |
| 25 | 25 | @@ -28,13 +28,14 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ |
| @@ -178,7 +178,7 @@ index 1edcf0c..707f1cf 100644 · 152 unmodified lines | ||
| 178 | 178 | props: ctx.props, |
| 179 | 179 | jurisdiction: options.jurisdiction |
| 180 | 180 | }); |
| 181 | −@@ -306,27 +344,66 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | |
| 181 | +@@ -306,27 +344,86 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | |
| 182 | 182 | if (!ws) { |
| 183 | 183 | await writer.close(); |
| 184 | 184 | return new Response("Failed to establish WS to DO", { status: 500 }); |
| @@ -187,7 +187,7 @@ index 1edcf0c..707f1cf 100644 · 2 unmodified lines | ||
| 187 | 187 | - ws.addEventListener("message", (event) => { |
| 188 | 188 | + } |
| 189 | 189 | + ws.accept(); |
| 190 | −+ const __closeSse = () => { | |
| 190 | ++ const __abortSse = () => { | |
| 191 | 191 | + __sseClosed = true; |
| 192 | 192 | + try { |
| 193 | 193 | + clearInterval(keepAlive); |
| @@ -197,22 +197,42 @@ index 1edcf0c..707f1cf 100644 · 3 unmodified lines | ||
| 197 | 197 | + } catch {} |
| 198 | 198 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); |
| 199 | 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 | ++ }; | |
| 200 | 220 | + const __forwardSse = (frame) => { |
| 201 | 221 | + if (__sseClosed) return __writeChain; |
| 202 | 222 | + const ageMs = Date.now() - __openedAt; |
| 203 | 223 | + if (ageMs > MAX_SSE_AGE_MS) { |
| 204 | −+ console.error(JSON.stringify({ | |
| 224 | ++ console.log(JSON.stringify({ | |
| 205 | 225 | + event: "sse_max_age_close", |
| 206 | 226 | + sessionId, |
| 207 | 227 | + variant: "streamable-get", |
| 208 | 228 | + ageMs, |
| 209 | 229 | + pendingBytes: __pendingBytes |
| 210 | 230 | + })); |
| 211 | −+ __closeSse(); | |
| 231 | ++ __closeSseGracefully(); | |
| 212 | 232 | + return Promise.resolve(); |
| 213 | 233 | + } |
| 214 | 234 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { |
| 215 | −+ __closeSse(); | |
| 235 | ++ __abortSse(); | |
| 216 | 236 | + return Promise.resolve(); |
| 217 | 237 | + } |
| 218 | 238 | + __pendingBytes += frame.byteLength; |
| @@ -260,7 +280,7 @@ index 1edcf0c..707f1cf 100644 · 41 unmodified lines | ||
| 260 | 280 | return new Response(readable, { |
| 261 | 281 | headers: { |
| 262 | 282 | "Cache-Control": "no-cache", |
| 263 | −@@ -389,10 +466,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { | |
| 283 | +@@ -389,10 +486,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { | |
| 264 | 284 | const url = new URL(request.url); |
| 265 | 285 | if (request.method === "GET" && basePattern.test(url)) { |
| 266 | 286 | const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); |
| @@ -281,7 +301,7 @@ index 1edcf0c..707f1cf 100644 · 14 unmodified lines | ||
| 281 | 301 | endpointUrl.pathname = encodeURI(`${basePath}/message`); |
| 282 | 302 | endpointUrl.searchParams.set("sessionId", sessionId); |
| 283 | 303 | const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; |
| 284 | −@@ -414,35 +497,74 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { | |
| 304 | +@@ -414,35 +517,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { | |
| 285 | 305 | console.error("Failed to establish WebSocket connection"); |
| 286 | 306 | await writer.close(); |
| 287 | 307 | return new Response("Failed to establish WebSocket connection", { status: 500 }); |
| @@ -290,7 +310,7 @@ index 1edcf0c..707f1cf 100644 · 2 unmodified lines | ||
| 290 | 310 | - ws.addEventListener("message", (event) => { |
| 291 | 311 | + } |
| 292 | 312 | + ws.accept(); |
| 293 | −+ const __closeSse = () => { | |
| 313 | ++ const __abortSse = () => { | |
| 294 | 314 | + __sseClosed = true; |
| 295 | 315 | + try { |
| 296 | 316 | + clearInterval(keepAlive); |
| @@ -300,22 +320,42 @@ index 1edcf0c..707f1cf 100644 · 3 unmodified lines | ||
| 300 | 320 | + } catch {} |
| 301 | 321 | + writer.abort(new Error("SSE client not draining")).catch(() => {}); |
| 302 | 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 | ++ }; | |
| 303 | 343 | + const __forwardSse = (frame) => { |
| 304 | 344 | + if (__sseClosed) return __writeChain; |
| 305 | 345 | + const ageMs = Date.now() - __openedAt; |
| 306 | 346 | + if (ageMs > MAX_SSE_AGE_MS) { |
| 307 | −+ console.error(JSON.stringify({ | |
| 347 | ++ console.log(JSON.stringify({ | |
| 308 | 348 | + event: "sse_max_age_close", |
| 309 | 349 | + sessionId, |
| 310 | 350 | + variant: "legacy-sse", |
| 311 | 351 | + ageMs, |
| 312 | 352 | + pendingBytes: __pendingBytes |
| 313 | 353 | + })); |
| 314 | −+ __closeSse(); | |
| 354 | ++ __closeSseGracefully(); | |
| 315 | 355 | + return Promise.resolve(); |
| 316 | 356 | + } |
| 317 | 357 | + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { |
| 318 | −+ __closeSse(); | |
| 358 | ++ __abortSse(); | |
| 319 | 359 | + return Promise.resolve(); |
| 320 | 360 | + } |
| 321 | 361 | + __pendingBytes += frame.byteLength; |
| @@ -374,13 +414,11 @@ index 1edcf0c..707f1cf 100644 · 52 unmodified lines | ||
| 374 | 414 | console.error("Error closing SSE connection:", error); |
| 375 | 415 | } |
| 376 | 416 | } |
| 377 | −@@ -1698,6 +1820,6 @@ var McpAgent = class McpAgent extends Agent { | |
| 417 | +@@ -1698,6 +1860,6 @@ var McpAgent = class McpAgent extends Agent { | |
| 378 | 418 | }; |
| 379 | 419 | McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; |
| 380 | 420 | //#endregion |
| 381 | 421 | -export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; |
| 382 | 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 }; |
| 383 | 423 | |
| 384 | −-//# sourceMappingURL=index.js.map | |
| 385 | −\ No newline at end of file | |
| 386 | −+//# sourceMappingURL=index.js.map | |
| 424 | + //# sourceMappingURL=index.js.map | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | #!/usr/bin/env bash |
| 2 | 2 | set -euo pipefail |
| 3 | 3 | APP=executor |
| 4 | −REPO=RhysSullivan/executor | |
| 4 | +REPO=UsefulSoftwareCo/executor | |
| 5 | 5 | |
| 6 | 6 | MUTED='\033[0;2m' |
| 7 | 7 | RED='\033[0;31m' |
| @@ -8,7 +8,7 @@ const REFERENCE_DIR = join(import.meta.dirname, "../.reference"); · 7 unmodified lines | ||
| 8 | 8 | const repos = [ |
| 9 | 9 | { name: "effect", url: "https://github.com/Effect-TS/effect.git" }, |
| 10 | 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 | 13 | name: "tanstack-router", |
| 14 | 14 | url: "https://github.com/TanStack/router.git", |