History

Merge remote-tracking branch 'origin/main' into health-checks-redo

# Conflicts: # apps/cloud/drizzle/meta/0008_snapshot.json # apps/cloud/drizzle/meta/_journal.json # packages/react/src/components/add-account-modal.tsx

Rhys Sullivan committed 4 months ago

Commit a3294242 parents a707e86 a380e34129 files · +6944 −313Browse files

Unified · Split

129 files
  1. .changeset/config.json +1 −1
  2. .github/ISSUE_TEMPLATE/desktop-crash.yml +1 −1
  3. .github/workflows/ci.yml +39 −13
  4. .github/workflows/pkg-pr-new.yml +8 −1
  5. .github/workflows/preview-sweep.yml +1 −1
  6. .github/workflows/preview.yml +9 −2
  7. .github/workflows/publish-desktop.yml +1 −1
  8. .github/workflows/publish-executor-package.yml +1 −1
  9. .github/workflows/publish-selfhost-docker.yml +42 −4
  10. .github/workflows/release.yml +1 −1
  11. .skills/cli-release/SKILL.md +1 −1
  12. README.md +1 −1
  13. RELEASING.md +1 −1
  14. apps/cli/src/service.test.ts +76 −6
  15. apps/cli/src/service.ts +45 −2
  16. apps/cloud/drizzle/0008_slow_mephisto.sql +1 −0
  17. apps/cloud/drizzle/0009_true_lake.sql +0 −0
  18. apps/cloud/drizzle/meta/0008_snapshot.json +12 −18
  19. apps/cloud/drizzle/meta/0009_snapshot.json +1290 −0
  20. apps/cloud/drizzle/meta/_journal.json +9 −2
  21. apps/cloud/scripts/code-migrations/gc-dead-dcr-oauth-clients.ts +174 −0
  22. apps/cloud/scripts/code-migrations/index.ts +4 −0
  23. apps/cloud/src/db/executor-schema.ts +1 −0
  24. apps/cloud/src/db/gc-dead-dcr-oauth-clients-code-migration.test.ts +207 −0
  25. apps/cloud/src/engine/execution-gate.ts +185 −0
  26. apps/cloud/src/engine/execution-limit-messages.ts +11 −0
  27. apps/cloud/src/engine/execution-rate-limit.ts +220 −0
  28. apps/cloud/src/engine/execution-stack-metered.ts +30 −9
  29. apps/cloud/src/env-augment.d.ts +14 −0
  30. apps/cloud/src/extensions/billing/service.ts +14 −1
  31. apps/cloud/src/server.ts +5 −0
  32. apps/cloud/src/web/components/support-options.tsx +1 −1
  33. apps/cloud/wrangler.jsonc +11 −0
  34. apps/desktop/electron-builder.config.ts +1 −1
  35. apps/desktop/electron.vite.config.ts +3 −1
  36. apps/desktop/package.json +1 −1
  37. apps/desktop/src/main/diagnostics.ts +1 −1
  38. apps/docs/docs.json +2 −2
  39. apps/docs/hosted/cloudflare.mdx +1 −1
  40. apps/docs/hosted/docker.mdx +3 −3
  41. apps/docs/index.mdx +1 −1
  42. apps/host-cloudflare/vite.config.ts +3 −1
  43. apps/host-selfhost/Dockerfile +1 −1
  44. apps/host-selfhost/README.md +1 −1
  45. apps/host-selfhost/vite.config.ts +3 −1
  46. apps/local/drizzle/0003_graceful_the_fallen.sql +1 −0
  47. apps/local/drizzle/meta/0003_snapshot.json +920 −0
  48. apps/local/drizzle/meta/_journal.json +7 −0
  49. apps/local/src/db/data-migrations.ts +9 −1
  50. apps/local/src/db/executor-schema.ts +1 −0
  51. apps/local/src/db/sqlite-fumadb.ts +6 −0
  52. apps/local/vite.config.ts +1 −1
  53. apps/marketing/src/components/LegalLayout.astro +1 −1
  54. apps/marketing/src/layouts/BlogPostLayout.astro +1 −1
  55. apps/marketing/src/lib/github.ts +1 −1
  56. apps/marketing/src/pages/blog/index.astro +1 −1
  57. apps/marketing/src/pages/index.astro +2 −2
  58. bun.lock +4 −2
  59. e2e/cloud/dcr-root-domain-isolation.test.ts +176 −0
  60. e2e/cloud/mcp-execution-limits.test.ts +200 −0
  61. e2e/package.json +1 −1
  62. e2e/setup/autumn-plans.ts +18 −0
  63. e2e/setup/cloud.boot.ts +7 −0
  64. e2e/setup/execution-limits.ts +14 −0
  65. e2e/src/surfaces/autumn.ts +123 −0
  66. package.json +3 −3
  67. packages/app/vite.config.ts +3 −1
  68. packages/core/api/src/handlers/oauth.ts +2 −0
  69. packages/core/api/src/oauth/api.ts +5 −0
  70. packages/core/cli/package.json +3 −3
  71. packages/core/config/package.json +3 −3
  72. packages/core/execution/README.md +1 −1
  73. packages/core/execution/package.json +3 −3
  74. packages/core/fumadb/package.json +1 −1
  75. packages/core/integrations-registry/package.json +3 −3
  76. packages/core/sdk/README.md +2 −2
  77. packages/core/sdk/package.json +5 −4
  78. packages/core/sdk/src/core-schema.ts +8 −0
  79. packages/core/sdk/src/core-tools.ts +14 −0
  80. packages/core/sdk/src/index.ts +13 −0
  81. packages/core/sdk/src/oauth-client.ts +14 −1
  82. packages/core/sdk/src/oauth-gc.test.ts +221 −0
  83. packages/core/sdk/src/oauth-gc.ts +156 −0
  84. packages/core/sdk/src/oauth-list-clients.test.ts +3 −1
  85. packages/core/sdk/src/oauth-register-dynamic.test.ts +385 −3
  86. packages/core/sdk/src/oauth-service.ts +206 −18
  87. packages/core/sdk/src/shared.ts +1 −0
  88. packages/core/sdk/src/sqlite-oauth-client-gc-migration.test.ts +351 −0
  89. packages/core/sdk/src/sqlite-oauth-client-gc-migration.ts +187 −0
  90. packages/core/vite-plugin/package.json +3 −3
  91. packages/hosts/cloudflare/package.json +1 −0
  92. packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts +360 −0
  93. packages/hosts/cloudflare/src/test-setup.ts +9 −0
  94. packages/hosts/cloudflare/src/test-stubs/cloudflare-email.ts +7 −0
  95. packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts +9 −0
  96. packages/hosts/cloudflare/vitest.config.ts +16 −0
  97. packages/kernel/core/README.md +1 −1
  98. packages/kernel/core/package.json +3 −3
  99. packages/kernel/runtime-quickjs/README.md +1 −1
  100. packages/kernel/runtime-quickjs/package.json +3 −3
  101. packages/plugins/desktop-settings/package.json +3 −3
  102. packages/plugins/example/package.json +3 −3
  103. packages/plugins/file-secrets/README.md +1 −1
  104. packages/plugins/file-secrets/package.json +3 −3
  105. packages/plugins/google/package.json +3 −3
  106. packages/plugins/graphql/README.md +1 −1
  107. packages/plugins/graphql/package.json +3 −3
  108. packages/plugins/keychain/README.md +1 −1
  109. packages/plugins/keychain/package.json +3 −3
  110. packages/plugins/mcp/README.md +1 −1
  111. packages/plugins/mcp/package.json +3 −3
  112. packages/plugins/microsoft/package.json +3 −3
  113. packages/plugins/onepassword/README.md +1 −1
  114. packages/plugins/onepassword/package.json +3 −3
  115. packages/plugins/openapi/README.md +1 −1
  116. packages/plugins/openapi/package.json +3 −3
  117. packages/plugins/toolkits/package.json +3 −3
  118. packages/plugins/workos-vault/package.json +3 −3
  119. packages/react/src/api/atoms.tsx +4 −1
  120. packages/react/src/components/add-account-modal.test.ts +38 −7
  121. packages/react/src/components/add-account-modal.tsx +159 −14
  122. packages/react/src/components/oauth-client-form.test.ts +29 −1
  123. packages/react/src/components/oauth-client-form.tsx +46 −4
  124. packages/react/src/plugins/dcr-root-domain-isolation.test.ts +125 −0
  125. packages/react/src/plugins/use-effective-oauth-client.test.ts +193 −1
  126. packages/react/src/plugins/use-effective-oauth-client.tsx +230 −52
  127. patches/agents@0.17.3.patch +138 −37
  128. scripts/install.sh +1 −1
  129. scripts/pull-references.ts +1 −1
.changeset/config.json+1−1
@@ -1,6 +1,6 @@
11 {
22 "$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" }],
44 "commit": false,
55 "fixed": [
66 [
.github/ISSUE_TEMPLATE/desktop-crash.yml+1−1
@@ -7,7 +7,7 @@ body: · 6 unmodified lines
77 attributes:
88 value: |
99 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)**
1111 guide — most crashes are fixed by updating to the latest version.
1212 If you're still stuck, the details below help us pin it down fast.
1313 - type: input
.github/workflows/ci.yml+39−13
@@ -13,7 +13,7 @@ concurrency: · 12 unmodified lines
1313 jobs:
1414 format:
1515 name: Format
16− runs-on: ubuntu-latest
16+ runs-on: blacksmith-4vcpu-ubuntu-2404
1717 steps:
1818 - uses: actions/checkout@v4
1919
@@ -29,13 +29,20 @@ jobs: · 9 unmodified lines
2929 restore-keys: |
3030 ${{ runner.os }}-bun-1.3.11-
3131
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+
3239 - run: bun install --frozen-lockfile
3340
3441 - run: bun run format:check
3542
3643 lint:
3744 name: Lint
38− runs-on: ubuntu-latest
45+ runs-on: blacksmith-4vcpu-ubuntu-2404
3946 steps:
4047 - uses: actions/checkout@v4
4148
@@ -51,13 +58,20 @@ jobs: · 9 unmodified lines
5158 restore-keys: |
5259 ${{ runner.os }}-bun-1.3.11-
5360
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+
5468 - run: bun install --frozen-lockfile
5569
5670 - run: bun run lint
5771
5872 typecheck:
5973 name: Typecheck
60− runs-on: ubuntu-latest
74+ runs-on: blacksmith-4vcpu-ubuntu-2404
6175 steps:
6276 - uses: actions/checkout@v4
6377
@@ -73,13 +87,20 @@ jobs: · 9 unmodified lines
7387 restore-keys: |
7488 ${{ runner.os }}-bun-1.3.11-
7589
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+
7697 - run: bun install --frozen-lockfile
7798
7899 - run: bun run typecheck
79100
80101 test:
81102 name: Test
82− runs-on: ubuntu-latest
103+ runs-on: blacksmith-4vcpu-ubuntu-2404
83104 env:
84105 TURBO_TEST_CONCURRENCY: 3
85106 steps:
@@ -130,7 +151,7 @@ jobs: · 44 unmodified lines
130151 - { target: cloud, shard: 7/8, shard-name: 7of8 }
131152 - { target: cloud, shard: 8/8, shard-name: 8of8 }
132153 - target: selfhost
133− runs-on: ubuntu-latest
154+ runs-on: blacksmith-4vcpu-ubuntu-2404
134155 timeout-minutes: 30
135156 steps:
136157 - uses: actions/checkout@v4
@@ -191,7 +212,7 @@ jobs: · 54 unmodified lines
191212 # Skipped on pull_request: the local scenario boots a real `executor web`
192213 # plus a browser and is currently flaky on PRs. Still runs on push to main.
193214 if: github.event_name != 'pull_request'
194− runs-on: ubuntu-latest
215+ runs-on: blacksmith-4vcpu-ubuntu-2404
195216 timeout-minutes: 20
196217 steps:
197218 - uses: actions/checkout@v4
@@ -244,7 +265,7 @@ jobs: · 46 unmodified lines
244265
245266 desktop-smoke:
246267 name: Desktop smoke build
247− runs-on: ubuntu-latest
268+ runs-on: blacksmith-4vcpu-ubuntu-2404
248269 steps:
249270 - uses: actions/checkout@v4
250271
@@ -260,6 +281,13 @@ jobs: · 9 unmodified lines
260281 restore-keys: |
261282 ${{ runner.os }}-bun-1.3.11-
262283
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+
263291 - run: bun install --frozen-lockfile
264292
265293 - name: Build web app
@@ -277,19 +305,17 @@ jobs: · 11 unmodified lines
277305
278306 selfhost-docker-smoke:
279307 name: Self-host Docker image
280− runs-on: ubuntu-latest
308+ runs-on: blacksmith-4vcpu-ubuntu-2404
281309 steps:
282310 - uses: actions/checkout@v4
283311
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
286314
287315 - name: Build self-host image
288− uses: docker/build-push-action@v6
316+ uses: useblacksmith/build-push-action@v2
289317 with:
290318 context: .
291319 file: apps/host-selfhost/Dockerfile
292320 push: false
293321 tags: executor-selfhost:ci
294− cache-from: type=gha
295− cache-to: type=gha,mode=max
.github/workflows/pkg-pr-new.yml+8−1
@@ -64,7 +64,7 @@ jobs: · 63 unmodified lines
6464 publish:
6565 name: Publish
6666 needs: build-preview-binary
67− runs-on: ubuntu-latest
67+ runs-on: blacksmith-4vcpu-ubuntu-2404
6868 permissions:
6969 contents: read
7070 pull-requests: write
@@ -77,6 +77,13 @@ jobs: · 6 unmodified lines
7777 with:
7878 bun-version: 1.3.11
7979
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+
8087 - run: bun install --frozen-lockfile
8188
8289 - name: Build executor preview wrapper
.github/workflows/preview-sweep.yml+1−1
@@ -19,7 +19,7 @@ permissions: · 18 unmodified lines
1919 jobs:
2020 sweep:
2121 name: Destroy previews for closed PRs
22− runs-on: ubuntu-latest
22+ runs-on: blacksmith-4vcpu-ubuntu-2404
2323 steps:
2424 - uses: actions/checkout@v4
2525
.github/workflows/preview.yml+9−2
@@ -27,7 +27,7 @@ jobs: · 26 unmodified lines
2727 deploy:
2828 name: Deploy preview
2929 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
3131 steps:
3232 - uses: actions/checkout@v4
3333
@@ -35,6 +35,13 @@ jobs: · 1 unmodified line
3535 with:
3636 bun-version: 1.3.11
3737
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+
3845 - run: bun install --frozen-lockfile
3946
4047 - name: Deploy
@@ -82,7 +89,7 @@ jobs: · 41 unmodified lines
8289 teardown:
8390 name: Tear down preview
8491 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
8693 steps:
8794 - uses: actions/checkout@v4
8895
.github/workflows/publish-desktop.yml+1−1
@@ -177,7 +177,7 @@ jobs: · 176 unmodified lines
177177
178178 release:
179179 needs: build
180− runs-on: ubuntu-latest
180+ runs-on: blacksmith-4vcpu-ubuntu-2404
181181 permissions:
182182 contents: write
183183
.github/workflows/publish-executor-package.yml+1−1
@@ -21,7 +21,7 @@ concurrency: · 20 unmodified lines
2121
2222 jobs:
2323 publish:
24− runs-on: ubuntu-latest
24+ runs-on: blacksmith-4vcpu-ubuntu-2404
2525 permissions:
2626 actions: write
2727 contents: write
.github/workflows/publish-selfhost-docker.yml+42−4
@@ -18,10 +18,12 @@ concurrency: · 17 unmodified lines
1818
1919 jobs:
2020 publish:
21− runs-on: ubuntu-latest
21+ runs-on: blacksmith-4vcpu-ubuntu-2404
2222 permissions:
2323 contents: read
2424 packages: write
25+ env:
26+ HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
2527
2628 steps:
2729 - name: Checkout
@@ -57,6 +59,7 @@ jobs: · 29 unmodified lines
5759 version="${RELEASE_TAG#v}"
5860 owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
5961 image="ghcr.io/${owner}/executor-selfhost"
62+ legacy_image="ghcr.io/rhyssullivan/executor-selfhost"
6063 revision="$(git rev-parse HEAD)"
6164
6265 if [[ "$version" == *-* ]]; then
@@ -74,6 +77,11 @@ jobs: · 11 unmodified lines
7477 echo "${image}:${version}"
7578 echo "${image}:${channel}"
7679 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"
7785 echo "labels<<LABELS"
7886 echo "org.opencontainers.image.title=Executor self-host"
7987 echo "org.opencontainers.image.description=Single-container self-hosted Executor"
@@ -86,8 +94,8 @@ jobs: · 6 unmodified lines
8694 - name: Set up QEMU
8795 uses: docker/setup-qemu-action@v3
8896
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
9199
92100 - name: Log in to GHCR
93101 uses: docker/login-action@v3
@@ -97,7 +105,8 @@ jobs: · 3 unmodified lines
97105 password: ${{ secrets.GITHUB_TOKEN }}
98106
99107 - name: Build and push self-host image
100− uses: docker/build-push-action@v6
108+ id: build
109+ uses: useblacksmith/build-push-action@v2
101110 with:
102111 context: .
103112 file: apps/host-selfhost/Dockerfile
@@ -105,3 +114,32 @@ jobs: · 1 unmodified line
105114 push: true
106115 tags: ${{ steps.image.outputs.tags }}
107116 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"
.github/workflows/release.yml+1−1
@@ -15,7 +15,7 @@ concurrency: · 14 unmodified lines
1515
1616 jobs:
1717 release:
18− runs-on: ubuntu-latest
18+ runs-on: blacksmith-4vcpu-ubuntu-2404
1919 permissions:
2020 actions: write
2121 contents: write
.skills/cli-release/SKILL.md+1−1
@@ -104,7 +104,7 @@ Identical to beta except skip `release:beta:start`/`stop`. Changesets produce a · 103 unmodified lines
104104 - **Never commit until the owner explicitly says so.** Set everything up in the working tree, run `git status`, and stop.
105105 - **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.
106106 - **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.
108108 - **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.
109109 - **Don't estimate time** — code is cheap to write. Focus on what to do, not how long it takes.
110110 - **Fact-check scope claims** before publishing. If release notes say "does not affect X", verify by reading the diff.
README.md+1−1
@@ -225,7 +225,7 @@ The browser e2e scenarios need Playwright's Chromium once per machine: · 224 unmodified lines
225225 ## Community
226226
227227 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).
229229
230230 ## License
231231
RELEASING.md+1−1
@@ -24,7 +24,7 @@ the self-host Docker image. · 23 unmodified lines
2424 - creates or updates the GitHub release with build artifacts
2525 - dispatches `.github/workflows/publish-desktop.yml`
2626 - 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`
2828 for `linux/amd64` and `linux/arm64`:
2929 - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest`
3030 - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta`
apps/cli/src/service.test.ts+76−6
@@ -1,4 +1,7 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+import { tmpdir } from "node:os";
4+import { join } from "node:path";
25
36 import {
47 cmdSetValue,
@@ -12,6 +15,14 @@ import { · 7 unmodified lines
1215 parseSchtasksRunning,
1316 } from "./service";
1417
18+const tempDirs: Array<string> = [];
19+
20+afterEach(() => {
21+ for (const dir of tempDirs.splice(0)) {
22+ rmSync(dir, { recursive: true, force: true });
23+ }
24+});
25+
1526 describe("service unit generation", () => {
1627 const launchdInput = {
1728 label: "sh.executor.daemon",
@@ -36,16 +47,49 @@ describe("service unit generation", () => { · 18 unmodified lines
3647 workingDirectory: "/Users/x/.executor",
3748 };
3849
50+ const makeExecutorBundle = (bundleIdentifier: string): string => {
51+ const root = mkdtempSync(join(tmpdir(), "executor-launchd-bundle-"));
52+ tempDirs.push(root);
53+ const contentsDir = join(root, "Executor.app", "Contents");
54+ const executableDir = join(contentsDir, "Resources", "executor");
55+ mkdirSync(executableDir, { recursive: true });
56+ writeFileSync(
57+ join(contentsDir, "Info.plist"),
58+ `<?xml version="1.0" encoding="UTF-8"?>
59+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
60+<plist version="1.0">
61+<dict>
62+ <key>CFBundleIdentifier</key>
63+ <string>${bundleIdentifier}</string>
64+</dict>
65+</plist>
66+`,
67+ );
68+ return join(executableDir, "executor");
69+ };
70+
71+ const expectLaunchdInvariants = (plist: string, input: typeof launchdInput): void => {
72+ const programArguments = input.programArguments
73+ .map((arg) => ` <string>${arg}</string>`)
74+ .join("\n");
75+
76+ expect(plist).toContain("<key>Label</key>");
77+ expect(plist).toContain(`<string>${input.label}</string>`);
78+ expect(plist).toContain(
79+ ` <key>ProgramArguments</key>\n <array>\n${programArguments}\n </array>`,
80+ );
81+ expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/);
82+ expect(plist).toMatch(
83+ /<key>KeepAlive<\/key>\s*<dict>\s*<key>SuccessfulExit<\/key>\s*<false\/>\s*<\/dict>/,
84+ );
85+ };
86+
3987 it("renders a launchd plist that restarts on crash but not clean stop", () => {
4088 const plist = generateLaunchdPlist(launchdInput);
89+ expectLaunchdInvariants(plist, launchdInput);
4190 expect(plist).toContain('<plist version="1.0">');
42− expect(plist).toContain("<key>Label</key>");
43− expect(plist).toContain("<string>sh.executor.daemon</string>");
44− expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/);
4591 // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout.
46− expect(plist).toContain("<key>KeepAlive</key>");
4792 expect(plist).toContain("<key>SuccessfulExit</key>");
48− expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/);
4993 expect(plist).toContain("<key>ProcessType</key>");
5094 expect(plist).toContain("<string>Background</string>");
5195 expect(plist).toContain("--foreground");
@@ -59,6 +103,32 @@ describe("service unit generation", () => { · 7 unmodified lines
59103 expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD");
60104 });
61105
106+ it("associates an app-bundled launchd plist with the enclosing bundle identifier", () => {
107+ const executablePath = makeExecutorBundle("sh.executor.desktop.test");
108+ const input = {
109+ ...launchdInput,
110+ programArguments: [executablePath, ...launchdInput.programArguments.slice(1)],
111+ };
112+ const plist = generateLaunchdPlist(input);
113+
114+ expectLaunchdInvariants(plist, input);
115+ expect(plist.match(/<key>AssociatedBundleIdentifiers<\/key>/g)).toHaveLength(1);
116+ expect(plist).toMatch(
117+ /<dict>\s*<key>Label<\/key>\s*<string>sh\.executor\.daemon<\/string>\s*<key>AssociatedBundleIdentifiers<\/key>\s*<array>\s*<string>sh\.executor\.desktop\.test<\/string>\s*<\/array>\s*<key>ProgramArguments<\/key>/,
118+ );
119+ });
120+
121+ it("omits app association for standalone launchd binaries", () => {
122+ const input = {
123+ ...launchdInput,
124+ programArguments: ["/usr/local/bin/executor", ...launchdInput.programArguments.slice(1)],
125+ };
126+ const plist = generateLaunchdPlist(input);
127+
128+ expectLaunchdInvariants(plist, input);
129+ expect(plist).not.toContain("AssociatedBundleIdentifiers");
130+ });
131+
62132 it("xml-escapes environment values", () => {
63133 const plist = generateLaunchdPlist({
64134 ...launchdInput,
apps/cli/src/service.ts+45−2
@@ -1,4 +1,5 @@
11 import { execFile } from "node:child_process";
2+import { readFileSync } from "node:fs";
23 import { homedir, userInfo } from "node:os";
34 import { FileSystem, Path } from "effect";
45 import type { PlatformError } from "effect/PlatformError";
@@ -143,6 +144,14 @@ const xmlEscape = (value: string): string => · 138 unmodified lines
143144 .replaceAll('"', "&quot;")
144145 .replaceAll("'", "&apos;");
145146
147+const xmlUnescape = (value: string): string =>
148+ value
149+ .replaceAll("&apos;", "'")
150+ .replaceAll("&quot;", '"')
151+ .replaceAll("&gt;", ">")
152+ .replaceAll("&lt;", "<")
153+ .replaceAll("&amp;", "&");
154+
146155 // ---------------------------------------------------------------------------
147156 // Shared service environment + program args
148157 // ---------------------------------------------------------------------------
@@ -209,6 +218,36 @@ const launchdPlistPath = (path: Path.Path): string => · 60 unmodified lines
209218
210219 const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");
211220
221+const DESKTOP_APP_BUNDLE_IDENTIFIER = "sh.executor.desktop";
222+
223+const enclosingAppBundlePath = (executablePath: string): string | null => {
224+ const match = executablePath.match(/^(.*\.app)\/Contents\//);
225+ return match?.[1] ?? null;
226+};
227+
228+const readBundleIdentifier = (appBundlePath: string): string | null => {
229+ try {
230+ const plist = readFileSync(`${appBundlePath}/Contents/Info.plist`, "utf8");
231+ const match = plist.match(/<key>\s*CFBundleIdentifier\s*<\/key>\s*<string>([^<]*)<\/string>/);
232+ const bundleIdentifier = match ? xmlUnescape(match[1].trim()) : "";
233+ return bundleIdentifier.length > 0 ? bundleIdentifier : null;
234+ } catch {
235+ return null;
236+ }
237+};
238+
239+const associatedBundleIdentifier = (programArguments: ReadonlyArray<string>): string | null => {
240+ const executablePath = programArguments[0];
241+ if (!executablePath) return null;
242+ const appBundlePath = enclosingAppBundlePath(executablePath);
243+ if (!appBundlePath) return null;
244+ const bundleIdentifier = readBundleIdentifier(appBundlePath);
245+ if (bundleIdentifier) return bundleIdentifier;
246+ return /(?:^|\/)Executor\.app\/Contents\//.test(executablePath)
247+ ? DESKTOP_APP_BUNDLE_IDENTIFIER
248+ : null;
249+};
250+
212251 export interface LaunchdPlistOptions {
213252 readonly label: string;
214253 readonly programArguments: ReadonlyArray<string>;
@@ -219,7 +258,7 @@ export interface LaunchdPlistOptions { · 4 unmodified lines
219258 }
220259
221260 /**
222− * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses
261+ * Render a user LaunchAgent plist. KeepAlive uses
223262 * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero
224263 * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →
225264 * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background
@@ -229,6 +268,10 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 3 unmodified lines
229268 const programArgs = options.programArguments
230269 .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
231270 .join("\n");
271+ const bundleIdentifier = associatedBundleIdentifier(options.programArguments);
272+ const associatedBundleIdentifiers = bundleIdentifier
273+ ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${xmlEscape(bundleIdentifier)}</string>\n </array>\n`
274+ : "";
232275 const envEntries = Object.entries(options.environment)
233276 .map(
234277 ([key, value]) =>
@@ -241,7 +284,7 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 6 unmodified lines
241284 <dict>
242285 <key>Label</key>
243286 <string>${xmlEscape(options.label)}</string>
244− <key>ProgramArguments</key>
287+${associatedBundleIdentifiers} <key>ProgramArguments</key>
245288 <array>
246289 ${programArgs}
247290 </array>
apps/cloud/drizzle/0008_slow_mephisto.sql+1−0
@@ -0,0 +1 @@
1+ALTER TABLE "oauth_client" ADD COLUMN "origin_issuer" text;
apps/cloud/drizzle/0009_true_lake.sqlrenamed from apps/cloud/drizzle/0008_true_lake.sql+0−0

File renamed without changes.

apps/cloud/drizzle/meta/0008_snapshot.json+12−18
@@ -1,5 +1,5 @@
11 {
2− "id": "0a228326-fd03-4910-8506-64b57af52736",
2+ "id": "a0208f9c-622b-4c94-bd09-33bcf37c2462",
33 "prevId": "2f557ed1-5afc-431b-8623-2a2162793f43",
44 "version": "7",
55 "dialect": "postgresql",
@@ -245,12 +245,6 @@ · 239 unmodified lines
245245 "primaryKey": false,
246246 "notNull": false
247247 },
248− "last_health": {
249− "name": "last_health",
250− "type": "json",
251− "primaryKey": false,
252− "notNull": false
253− },
254248 "tools_synced_at": {
255249 "name": "tools_synced_at",
256250 "type": "bigint",
@@ -531,24 +525,18 @@ · 274 unmodified lines
531525 "primaryKey": false,
532526 "notNull": false
533527 },
534− "config": {
535− "name": "config",
536− "type": "json",
528+ "config_revised_at": {
529+ "name": "config_revised_at",
530+ "type": "bigint",
537531 "primaryKey": false,
538532 "notNull": false
539533 },
540− "health_check": {
541− "name": "health_check",
534+ "config": {
535+ "name": "config",
542536 "type": "json",
543537 "primaryKey": false,
544538 "notNull": false
545539 },
546− "config_revised_at": {
547− "name": "config_revised_at",
548− "type": "bigint",
549− "primaryKey": false,
550− "notNull": false
551− },
552540 "can_remove": {
553541 "name": "can_remove",
554542 "type": "boolean",
@@ -676,6 +664,12 @@ · 121 unmodified lines
676664 "primaryKey": false,
677665 "notNull": false
678666 },
667+ "origin_issuer": {
668+ "name": "origin_issuer",
669+ "type": "text",
670+ "primaryKey": false,
671+ "notNull": false
672+ },
679673 "created_at": {
680674 "name": "created_at",
681675 "type": "timestamp",
apps/cloud/drizzle/meta/0009_snapshot.json+1290−0
@@ -0,0 +1,1290 @@
1+{
2+ "id": "b07ddbda-be11-4679-9f00-16d1b3b5e879",
3+ "prevId": "a0208f9c-622b-4c94-bd09-33bcf37c2462",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "created_at": {
18+ "name": "created_at",
19+ "type": "timestamp with time zone",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "default": "now()"
23+ }
24+ },
25+ "indexes": {},
26+ "foreignKeys": {},
27+ "compositePrimaryKeys": {},
28+ "uniqueConstraints": {},
29+ "policies": {},
30+ "checkConstraints": {},
31+ "isRLSEnabled": false
32+ },
33+ "public.memberships": {
34+ "name": "memberships",
35+ "schema": "",
36+ "columns": {
37+ "account_id": {
38+ "name": "account_id",
39+ "type": "text",
40+ "primaryKey": false,
41+ "notNull": true
42+ },
43+ "organization_id": {
44+ "name": "organization_id",
45+ "type": "text",
46+ "primaryKey": false,
47+ "notNull": true
48+ },
49+ "created_at": {
50+ "name": "created_at",
51+ "type": "timestamp with time zone",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "default": "now()"
55+ }
56+ },
57+ "indexes": {},
58+ "foreignKeys": {
59+ "memberships_account_id_accounts_id_fk": {
60+ "name": "memberships_account_id_accounts_id_fk",
61+ "tableFrom": "memberships",
62+ "tableTo": "accounts",
63+ "columnsFrom": ["account_id"],
64+ "columnsTo": ["id"],
65+ "onDelete": "cascade",
66+ "onUpdate": "no action"
67+ },
68+ "memberships_organization_id_organizations_id_fk": {
69+ "name": "memberships_organization_id_organizations_id_fk",
70+ "tableFrom": "memberships",
71+ "tableTo": "organizations",
72+ "columnsFrom": ["organization_id"],
73+ "columnsTo": ["id"],
74+ "onDelete": "cascade",
75+ "onUpdate": "no action"
76+ }
77+ },
78+ "compositePrimaryKeys": {
79+ "memberships_account_id_organization_id_pk": {
80+ "name": "memberships_account_id_organization_id_pk",
81+ "columns": ["account_id", "organization_id"]
82+ }
83+ },
84+ "uniqueConstraints": {},
85+ "policies": {},
86+ "checkConstraints": {},
87+ "isRLSEnabled": false
88+ },
89+ "public.organizations": {
90+ "name": "organizations",
91+ "schema": "",
92+ "columns": {
93+ "id": {
94+ "name": "id",
95+ "type": "text",
96+ "primaryKey": true,
97+ "notNull": true
98+ },
99+ "name": {
100+ "name": "name",
101+ "type": "text",
102+ "primaryKey": false,
103+ "notNull": true
104+ },
105+ "slug": {
106+ "name": "slug",
107+ "type": "text",
108+ "primaryKey": false,
109+ "notNull": true
110+ },
111+ "created_at": {
112+ "name": "created_at",
113+ "type": "timestamp with time zone",
114+ "primaryKey": false,
115+ "notNull": true,
116+ "default": "now()"
117+ }
118+ },
119+ "indexes": {
120+ "organizations_slug_unique": {
121+ "name": "organizations_slug_unique",
122+ "columns": [
123+ {
124+ "expression": "slug",
125+ "isExpression": false,
126+ "asc": true,
127+ "nulls": "last"
128+ }
129+ ],
130+ "isUnique": true,
131+ "concurrently": false,
132+ "method": "btree",
133+ "with": {}
134+ }
135+ },
136+ "foreignKeys": {},
137+ "compositePrimaryKeys": {},
138+ "uniqueConstraints": {},
139+ "policies": {},
140+ "checkConstraints": {},
141+ "isRLSEnabled": false
142+ },
143+ "public.blob": {
144+ "name": "blob",
145+ "schema": "",
146+ "columns": {
147+ "namespace": {
148+ "name": "namespace",
149+ "type": "varchar(255)",
150+ "primaryKey": false,
151+ "notNull": true
152+ },
153+ "key": {
154+ "name": "key",
155+ "type": "varchar(255)",
156+ "primaryKey": false,
157+ "notNull": true
158+ },
159+ "value": {
160+ "name": "value",
161+ "type": "text",
162+ "primaryKey": false,
163+ "notNull": true
164+ },
165+ "row_id": {
166+ "name": "row_id",
167+ "type": "varchar(255)",
168+ "primaryKey": true,
169+ "notNull": true
170+ },
171+ "id": {
172+ "name": "id",
173+ "type": "varchar(255)",
174+ "primaryKey": false,
175+ "notNull": true
176+ }
177+ },
178+ "indexes": {
179+ "blob_id_uidx": {
180+ "name": "blob_id_uidx",
181+ "columns": [
182+ {
183+ "expression": "id",
184+ "isExpression": false,
185+ "asc": true,
186+ "nulls": "last"
187+ }
188+ ],
189+ "isUnique": true,
190+ "concurrently": false,
191+ "method": "btree",
192+ "with": {}
193+ }
194+ },
195+ "foreignKeys": {},
196+ "compositePrimaryKeys": {},
197+ "uniqueConstraints": {},
198+ "policies": {},
199+ "checkConstraints": {},
200+ "isRLSEnabled": false
201+ },
202+ "public.connection": {
203+ "name": "connection",
204+ "schema": "",
205+ "columns": {
206+ "integration": {
207+ "name": "integration",
208+ "type": "varchar(255)",
209+ "primaryKey": false,
210+ "notNull": true
211+ },
212+ "name": {
213+ "name": "name",
214+ "type": "varchar(255)",
215+ "primaryKey": false,
216+ "notNull": true
217+ },
218+ "template": {
219+ "name": "template",
220+ "type": "text",
221+ "primaryKey": false,
222+ "notNull": true
223+ },
224+ "provider": {
225+ "name": "provider",
226+ "type": "text",
227+ "primaryKey": false,
228+ "notNull": true
229+ },
230+ "item_ids": {
231+ "name": "item_ids",
232+ "type": "json",
233+ "primaryKey": false,
234+ "notNull": true
235+ },
236+ "identity_label": {
237+ "name": "identity_label",
238+ "type": "text",
239+ "primaryKey": false,
240+ "notNull": false
241+ },
242+ "description": {
243+ "name": "description",
244+ "type": "text",
245+ "primaryKey": false,
246+ "notNull": false
247+ },
248+ "last_health": {
249+ "name": "last_health",
250+ "type": "json",
251+ "primaryKey": false,
252+ "notNull": false
253+ },
254+ "tools_synced_at": {
255+ "name": "tools_synced_at",
256+ "type": "bigint",
257+ "primaryKey": false,
258+ "notNull": false
259+ },
260+ "oauth_client": {
261+ "name": "oauth_client",
262+ "type": "text",
263+ "primaryKey": false,
264+ "notNull": false
265+ },
266+ "oauth_client_owner": {
267+ "name": "oauth_client_owner",
268+ "type": "text",
269+ "primaryKey": false,
270+ "notNull": false
271+ },
272+ "refresh_item_id": {
273+ "name": "refresh_item_id",
274+ "type": "text",
275+ "primaryKey": false,
276+ "notNull": false
277+ },
278+ "expires_at": {
279+ "name": "expires_at",
280+ "type": "bigint",
281+ "primaryKey": false,
282+ "notNull": false
283+ },
284+ "oauth_scope": {
285+ "name": "oauth_scope",
286+ "type": "text",
287+ "primaryKey": false,
288+ "notNull": false
289+ },
290+ "oauth_token_url": {
291+ "name": "oauth_token_url",
292+ "type": "text",
293+ "primaryKey": false,
294+ "notNull": false
295+ },
296+ "provider_state": {
297+ "name": "provider_state",
298+ "type": "json",
299+ "primaryKey": false,
300+ "notNull": false
301+ },
302+ "created_at": {
303+ "name": "created_at",
304+ "type": "timestamp",
305+ "primaryKey": false,
306+ "notNull": true
307+ },
308+ "updated_at": {
309+ "name": "updated_at",
310+ "type": "timestamp",
311+ "primaryKey": false,
312+ "notNull": true
313+ },
314+ "row_id": {
315+ "name": "row_id",
316+ "type": "varchar(255)",
317+ "primaryKey": true,
318+ "notNull": true
319+ },
320+ "tenant": {
321+ "name": "tenant",
322+ "type": "varchar(255)",
323+ "primaryKey": false,
324+ "notNull": true
325+ },
326+ "owner": {
327+ "name": "owner",
328+ "type": "varchar(255)",
329+ "primaryKey": false,
330+ "notNull": true
331+ },
332+ "subject": {
333+ "name": "subject",
334+ "type": "varchar(255)",
335+ "primaryKey": false,
336+ "notNull": true
337+ }
338+ },
339+ "indexes": {
340+ "connection_uidx": {
341+ "name": "connection_uidx",
342+ "columns": [
343+ {
344+ "expression": "tenant",
345+ "isExpression": false,
346+ "asc": true,
347+ "nulls": "last"
348+ },
349+ {
350+ "expression": "owner",
351+ "isExpression": false,
352+ "asc": true,
353+ "nulls": "last"
354+ },
355+ {
356+ "expression": "subject",
357+ "isExpression": false,
358+ "asc": true,
359+ "nulls": "last"
360+ },
361+ {
362+ "expression": "integration",
363+ "isExpression": false,
364+ "asc": true,
365+ "nulls": "last"
366+ },
367+ {
368+ "expression": "name",
369+ "isExpression": false,
370+ "asc": true,
371+ "nulls": "last"
372+ }
373+ ],
374+ "isUnique": true,
375+ "concurrently": false,
376+ "method": "btree",
377+ "with": {}
378+ }
379+ },
380+ "foreignKeys": {},
381+ "compositePrimaryKeys": {},
382+ "uniqueConstraints": {},
383+ "policies": {},
384+ "checkConstraints": {},
385+ "isRLSEnabled": false
386+ },
387+ "public.definition": {
388+ "name": "definition",
389+ "schema": "",
390+ "columns": {
391+ "integration": {
392+ "name": "integration",
393+ "type": "varchar(255)",
394+ "primaryKey": false,
395+ "notNull": true
396+ },
397+ "connection": {
398+ "name": "connection",
399+ "type": "varchar(255)",
400+ "primaryKey": false,
401+ "notNull": true
402+ },
403+ "plugin_id": {
404+ "name": "plugin_id",
405+ "type": "text",
406+ "primaryKey": false,
407+ "notNull": true
408+ },
409+ "name": {
410+ "name": "name",
411+ "type": "text",
412+ "primaryKey": false,
413+ "notNull": true
414+ },
415+ "schema": {
416+ "name": "schema",
417+ "type": "json",
418+ "primaryKey": false,
419+ "notNull": true
420+ },
421+ "created_at": {
422+ "name": "created_at",
423+ "type": "timestamp",
424+ "primaryKey": false,
425+ "notNull": true
426+ },
427+ "row_id": {
428+ "name": "row_id",
429+ "type": "varchar(255)",
430+ "primaryKey": true,
431+ "notNull": true
432+ },
433+ "tenant": {
434+ "name": "tenant",
435+ "type": "varchar(255)",
436+ "primaryKey": false,
437+ "notNull": true
438+ },
439+ "owner": {
440+ "name": "owner",
441+ "type": "varchar(255)",
442+ "primaryKey": false,
443+ "notNull": true
444+ },
445+ "subject": {
446+ "name": "subject",
447+ "type": "varchar(255)",
448+ "primaryKey": false,
449+ "notNull": true
450+ }
451+ },
452+ "indexes": {
453+ "definition_uidx": {
454+ "name": "definition_uidx",
455+ "columns": [
456+ {
457+ "expression": "tenant",
458+ "isExpression": false,
459+ "asc": true,
460+ "nulls": "last"
461+ },
462+ {
463+ "expression": "owner",
464+ "isExpression": false,
465+ "asc": true,
466+ "nulls": "last"
467+ },
468+ {
469+ "expression": "subject",
470+ "isExpression": false,
471+ "asc": true,
472+ "nulls": "last"
473+ },
474+ {
475+ "expression": "integration",
476+ "isExpression": false,
477+ "asc": true,
478+ "nulls": "last"
479+ },
480+ {
481+ "expression": "connection",
482+ "isExpression": false,
483+ "asc": true,
484+ "nulls": "last"
485+ },
486+ {
487+ "expression": "name",
488+ "isExpression": false,
489+ "asc": true,
490+ "nulls": "last"
491+ }
492+ ],
493+ "isUnique": true,
494+ "concurrently": false,
495+ "method": "btree",
496+ "with": {}
497+ }
498+ },
499+ "foreignKeys": {},
500+ "compositePrimaryKeys": {},
501+ "uniqueConstraints": {},
502+ "policies": {},
503+ "checkConstraints": {},
504+ "isRLSEnabled": false
505+ },
506+ "public.integration": {
507+ "name": "integration",
508+ "schema": "",
509+ "columns": {
510+ "slug": {
511+ "name": "slug",
512+ "type": "varchar(255)",
513+ "primaryKey": false,
514+ "notNull": true
515+ },
516+ "plugin_id": {
517+ "name": "plugin_id",
518+ "type": "text",
519+ "primaryKey": false,
520+ "notNull": true
521+ },
522+ "name": {
523+ "name": "name",
524+ "type": "text",
525+ "primaryKey": false,
526+ "notNull": false
527+ },
528+ "description": {
529+ "name": "description",
530+ "type": "text",
531+ "primaryKey": false,
532+ "notNull": false
533+ },
534+ "config_revised_at": {
535+ "name": "config_revised_at",
536+ "type": "bigint",
537+ "primaryKey": false,
538+ "notNull": false
539+ },
540+ "config": {
541+ "name": "config",
542+ "type": "json",
543+ "primaryKey": false,
544+ "notNull": false
545+ },
546+ "health_check": {
547+ "name": "health_check",
548+ "type": "json",
549+ "primaryKey": false,
550+ "notNull": false
551+ },
552+ "can_remove": {
553+ "name": "can_remove",
554+ "type": "boolean",
555+ "primaryKey": false,
556+ "notNull": true,
557+ "default": true
558+ },
559+ "can_refresh": {
560+ "name": "can_refresh",
561+ "type": "boolean",
562+ "primaryKey": false,
563+ "notNull": true,
564+ "default": false
565+ },
566+ "created_at": {
567+ "name": "created_at",
568+ "type": "timestamp",
569+ "primaryKey": false,
570+ "notNull": true
571+ },
572+ "updated_at": {
573+ "name": "updated_at",
574+ "type": "timestamp",
575+ "primaryKey": false,
576+ "notNull": true
577+ },
578+ "row_id": {
579+ "name": "row_id",
580+ "type": "varchar(255)",
581+ "primaryKey": true,
582+ "notNull": true
583+ },
584+ "tenant": {
585+ "name": "tenant",
586+ "type": "varchar(255)",
587+ "primaryKey": false,
588+ "notNull": true
589+ }
590+ },
591+ "indexes": {
592+ "integration_uidx": {
593+ "name": "integration_uidx",
594+ "columns": [
595+ {
596+ "expression": "tenant",
597+ "isExpression": false,
598+ "asc": true,
599+ "nulls": "last"
600+ },
601+ {
602+ "expression": "slug",
603+ "isExpression": false,
604+ "asc": true,
605+ "nulls": "last"
606+ }
607+ ],
608+ "isUnique": true,
609+ "concurrently": false,
610+ "method": "btree",
611+ "with": {}
612+ }
613+ },
614+ "foreignKeys": {},
615+ "compositePrimaryKeys": {},
616+ "uniqueConstraints": {},
617+ "policies": {},
618+ "checkConstraints": {},
619+ "isRLSEnabled": false
620+ },
621+ "public.oauth_client": {
622+ "name": "oauth_client",
623+ "schema": "",
624+ "columns": {
625+ "slug": {
626+ "name": "slug",
627+ "type": "varchar(255)",
628+ "primaryKey": false,
629+ "notNull": true
630+ },
631+ "authorization_url": {
632+ "name": "authorization_url",
633+ "type": "text",
634+ "primaryKey": false,
635+ "notNull": true
636+ },
637+ "token_url": {
638+ "name": "token_url",
639+ "type": "text",
640+ "primaryKey": false,
641+ "notNull": true
642+ },
643+ "grant": {
644+ "name": "grant",
645+ "type": "text",
646+ "primaryKey": false,
647+ "notNull": true
648+ },
649+ "client_id": {
650+ "name": "client_id",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": true
654+ },
655+ "client_secret_item_id": {
656+ "name": "client_secret_item_id",
657+ "type": "text",
658+ "primaryKey": false,
659+ "notNull": false
660+ },
661+ "resource": {
662+ "name": "resource",
663+ "type": "text",
664+ "primaryKey": false,
665+ "notNull": false
666+ },
667+ "origin_kind": {
668+ "name": "origin_kind",
669+ "type": "text",
670+ "primaryKey": false,
671+ "notNull": false
672+ },
673+ "origin_integration": {
674+ "name": "origin_integration",
675+ "type": "text",
676+ "primaryKey": false,
677+ "notNull": false
678+ },
679+ "origin_issuer": {
680+ "name": "origin_issuer",
681+ "type": "text",
682+ "primaryKey": false,
683+ "notNull": false
684+ },
685+ "created_at": {
686+ "name": "created_at",
687+ "type": "timestamp",
688+ "primaryKey": false,
689+ "notNull": true
690+ },
691+ "row_id": {
692+ "name": "row_id",
693+ "type": "varchar(255)",
694+ "primaryKey": true,
695+ "notNull": true
696+ },
697+ "tenant": {
698+ "name": "tenant",
699+ "type": "varchar(255)",
700+ "primaryKey": false,
701+ "notNull": true
702+ },
703+ "owner": {
704+ "name": "owner",
705+ "type": "varchar(255)",
706+ "primaryKey": false,
707+ "notNull": true
708+ },
709+ "subject": {
710+ "name": "subject",
711+ "type": "varchar(255)",
712+ "primaryKey": false,
713+ "notNull": true
714+ }
715+ },
716+ "indexes": {
717+ "oauth_client_uidx": {
718+ "name": "oauth_client_uidx",
719+ "columns": [
720+ {
721+ "expression": "tenant",
722+ "isExpression": false,
723+ "asc": true,
724+ "nulls": "last"
725+ },
726+ {
727+ "expression": "owner",
728+ "isExpression": false,
729+ "asc": true,
730+ "nulls": "last"
731+ },
732+ {
733+ "expression": "subject",
734+ "isExpression": false,
735+ "asc": true,
736+ "nulls": "last"
737+ },
738+ {
739+ "expression": "slug",
740+ "isExpression": false,
741+ "asc": true,
742+ "nulls": "last"
743+ }
744+ ],
745+ "isUnique": true,
746+ "concurrently": false,
747+ "method": "btree",
748+ "with": {}
749+ }
750+ },
751+ "foreignKeys": {},
752+ "compositePrimaryKeys": {},
753+ "uniqueConstraints": {},
754+ "policies": {},
755+ "checkConstraints": {},
756+ "isRLSEnabled": false
757+ },
758+ "public.oauth_session": {
759+ "name": "oauth_session",
760+ "schema": "",
761+ "columns": {
762+ "state": {
763+ "name": "state",
764+ "type": "varchar(255)",
765+ "primaryKey": false,
766+ "notNull": true
767+ },
768+ "client_slug": {
769+ "name": "client_slug",
770+ "type": "text",
771+ "primaryKey": false,
772+ "notNull": true
773+ },
774+ "integration": {
775+ "name": "integration",
776+ "type": "text",
777+ "primaryKey": false,
778+ "notNull": true
779+ },
780+ "name": {
781+ "name": "name",
782+ "type": "text",
783+ "primaryKey": false,
784+ "notNull": true
785+ },
786+ "template": {
787+ "name": "template",
788+ "type": "text",
789+ "primaryKey": false,
790+ "notNull": true
791+ },
792+ "redirect_url": {
793+ "name": "redirect_url",
794+ "type": "text",
795+ "primaryKey": false,
796+ "notNull": true
797+ },
798+ "pkce_verifier": {
799+ "name": "pkce_verifier",
800+ "type": "text",
801+ "primaryKey": false,
802+ "notNull": false
803+ },
804+ "identity_label": {
805+ "name": "identity_label",
806+ "type": "text",
807+ "primaryKey": false,
808+ "notNull": false
809+ },
810+ "payload": {
811+ "name": "payload",
812+ "type": "json",
813+ "primaryKey": false,
814+ "notNull": true
815+ },
816+ "expires_at": {
817+ "name": "expires_at",
818+ "type": "bigint",
819+ "primaryKey": false,
820+ "notNull": true
821+ },
822+ "created_at": {
823+ "name": "created_at",
824+ "type": "timestamp",
825+ "primaryKey": false,
826+ "notNull": true
827+ },
828+ "row_id": {
829+ "name": "row_id",
830+ "type": "varchar(255)",
831+ "primaryKey": true,
832+ "notNull": true
833+ },
834+ "tenant": {
835+ "name": "tenant",
836+ "type": "varchar(255)",
837+ "primaryKey": false,
838+ "notNull": true
839+ },
840+ "owner": {
841+ "name": "owner",
842+ "type": "varchar(255)",
843+ "primaryKey": false,
844+ "notNull": true
845+ },
846+ "subject": {
847+ "name": "subject",
848+ "type": "varchar(255)",
849+ "primaryKey": false,
850+ "notNull": true
851+ }
852+ },
853+ "indexes": {
854+ "oauth_session_uidx": {
855+ "name": "oauth_session_uidx",
856+ "columns": [
857+ {
858+ "expression": "tenant",
859+ "isExpression": false,
860+ "asc": true,
861+ "nulls": "last"
862+ },
863+ {
864+ "expression": "state",
865+ "isExpression": false,
866+ "asc": true,
867+ "nulls": "last"
868+ }
869+ ],
870+ "isUnique": true,
871+ "concurrently": false,
872+ "method": "btree",
873+ "with": {}
874+ }
875+ },
876+ "foreignKeys": {},
877+ "compositePrimaryKeys": {},
878+ "uniqueConstraints": {},
879+ "policies": {},
880+ "checkConstraints": {},
881+ "isRLSEnabled": false
882+ },
883+ "public.plugin_storage": {
884+ "name": "plugin_storage",
885+ "schema": "",
886+ "columns": {
887+ "plugin_id": {
888+ "name": "plugin_id",
889+ "type": "varchar(255)",
890+ "primaryKey": false,
891+ "notNull": true
892+ },
893+ "collection": {
894+ "name": "collection",
895+ "type": "varchar(255)",
896+ "primaryKey": false,
897+ "notNull": true
898+ },
899+ "key": {
900+ "name": "key",
901+ "type": "varchar(255)",
902+ "primaryKey": false,
903+ "notNull": true
904+ },
905+ "data": {
906+ "name": "data",
907+ "type": "json",
908+ "primaryKey": false,
909+ "notNull": true
910+ },
911+ "created_at": {
912+ "name": "created_at",
913+ "type": "timestamp",
914+ "primaryKey": false,
915+ "notNull": true
916+ },
917+ "updated_at": {
918+ "name": "updated_at",
919+ "type": "timestamp",
920+ "primaryKey": false,
921+ "notNull": true
922+ },
923+ "row_id": {
924+ "name": "row_id",
925+ "type": "varchar(255)",
926+ "primaryKey": true,
927+ "notNull": true
928+ },
929+ "tenant": {
930+ "name": "tenant",
931+ "type": "varchar(255)",
932+ "primaryKey": false,
933+ "notNull": true
934+ },
935+ "owner": {
936+ "name": "owner",
937+ "type": "varchar(255)",
938+ "primaryKey": false,
939+ "notNull": true
940+ },
941+ "subject": {
942+ "name": "subject",
943+ "type": "varchar(255)",
944+ "primaryKey": false,
945+ "notNull": true
946+ }
947+ },
948+ "indexes": {
949+ "plugin_storage_uidx": {
950+ "name": "plugin_storage_uidx",
951+ "columns": [
952+ {
953+ "expression": "tenant",
954+ "isExpression": false,
955+ "asc": true,
956+ "nulls": "last"
957+ },
958+ {
959+ "expression": "owner",
960+ "isExpression": false,
961+ "asc": true,
962+ "nulls": "last"
963+ },
964+ {
965+ "expression": "subject",
966+ "isExpression": false,
967+ "asc": true,
968+ "nulls": "last"
969+ },
970+ {
971+ "expression": "plugin_id",
972+ "isExpression": false,
973+ "asc": true,
974+ "nulls": "last"
975+ },
976+ {
977+ "expression": "collection",
978+ "isExpression": false,
979+ "asc": true,
980+ "nulls": "last"
981+ },
982+ {
983+ "expression": "key",
984+ "isExpression": false,
985+ "asc": true,
986+ "nulls": "last"
987+ }
988+ ],
989+ "isUnique": true,
990+ "concurrently": false,
991+ "method": "btree",
992+ "with": {}
993+ }
994+ },
995+ "foreignKeys": {},
996+ "compositePrimaryKeys": {},
997+ "uniqueConstraints": {},
998+ "policies": {},
999+ "checkConstraints": {},
1000+ "isRLSEnabled": false
1001+ },
1002+ "public.private_executor_cloud_settings": {
1003+ "name": "private_executor_cloud_settings",
1004+ "schema": "",
1005+ "columns": {
1006+ "id": {
1007+ "name": "id",
1008+ "type": "varchar(255)",
1009+ "primaryKey": true,
1010+ "notNull": true
1011+ },
1012+ "version": {
1013+ "name": "version",
1014+ "type": "varchar(255)",
1015+ "primaryKey": false,
1016+ "notNull": true,
1017+ "default": "'1.0.0'"
1018+ }
1019+ },
1020+ "indexes": {},
1021+ "foreignKeys": {},
1022+ "compositePrimaryKeys": {},
1023+ "uniqueConstraints": {},
1024+ "policies": {},
1025+ "checkConstraints": {},
1026+ "isRLSEnabled": false
1027+ },
1028+ "public.tool": {
1029+ "name": "tool",
1030+ "schema": "",
1031+ "columns": {
1032+ "integration": {
1033+ "name": "integration",
1034+ "type": "varchar(255)",
1035+ "primaryKey": false,
1036+ "notNull": true
1037+ },
1038+ "connection": {
1039+ "name": "connection",
1040+ "type": "varchar(255)",
1041+ "primaryKey": false,
1042+ "notNull": true
1043+ },
1044+ "plugin_id": {
1045+ "name": "plugin_id",
1046+ "type": "text",
1047+ "primaryKey": false,
1048+ "notNull": true
1049+ },
1050+ "name": {
1051+ "name": "name",
1052+ "type": "varchar(255)",
1053+ "primaryKey": false,
1054+ "notNull": true
1055+ },
1056+ "description": {
1057+ "name": "description",
1058+ "type": "text",
1059+ "primaryKey": false,
1060+ "notNull": true
1061+ },
1062+ "input_schema": {
1063+ "name": "input_schema",
1064+ "type": "json",
1065+ "primaryKey": false,
1066+ "notNull": false
1067+ },
1068+ "output_schema": {
1069+ "name": "output_schema",
1070+ "type": "json",
1071+ "primaryKey": false,
1072+ "notNull": false
1073+ },
1074+ "annotations": {
1075+ "name": "annotations",
1076+ "type": "json",
1077+ "primaryKey": false,
1078+ "notNull": false
1079+ },
1080+ "created_at": {
1081+ "name": "created_at",
1082+ "type": "timestamp",
1083+ "primaryKey": false,
1084+ "notNull": true
1085+ },
1086+ "updated_at": {
1087+ "name": "updated_at",
1088+ "type": "timestamp",
1089+ "primaryKey": false,
1090+ "notNull": true
1091+ },
1092+ "row_id": {
1093+ "name": "row_id",
1094+ "type": "varchar(255)",
1095+ "primaryKey": true,
1096+ "notNull": true
1097+ },
1098+ "tenant": {
1099+ "name": "tenant",
1100+ "type": "varchar(255)",
1101+ "primaryKey": false,
1102+ "notNull": true
1103+ },
1104+ "owner": {
1105+ "name": "owner",
1106+ "type": "varchar(255)",
1107+ "primaryKey": false,
1108+ "notNull": true
1109+ },
1110+ "subject": {
1111+ "name": "subject",
1112+ "type": "varchar(255)",
1113+ "primaryKey": false,
1114+ "notNull": true
1115+ }
1116+ },
1117+ "indexes": {
1118+ "tool_uidx": {
1119+ "name": "tool_uidx",
1120+ "columns": [
1121+ {
1122+ "expression": "tenant",
1123+ "isExpression": false,
1124+ "asc": true,
1125+ "nulls": "last"
1126+ },
1127+ {
1128+ "expression": "owner",
1129+ "isExpression": false,
1130+ "asc": true,
1131+ "nulls": "last"
1132+ },
1133+ {
1134+ "expression": "subject",
1135+ "isExpression": false,
1136+ "asc": true,
1137+ "nulls": "last"
1138+ },
1139+ {
1140+ "expression": "integration",
1141+ "isExpression": false,
1142+ "asc": true,
1143+ "nulls": "last"
1144+ },
1145+ {
1146+ "expression": "connection",
1147+ "isExpression": false,
1148+ "asc": true,
1149+ "nulls": "last"
1150+ },
1151+ {
1152+ "expression": "name",
1153+ "isExpression": false,
1154+ "asc": true,
1155+ "nulls": "last"
1156+ }
1157+ ],
1158+ "isUnique": true,
1159+ "concurrently": false,
1160+ "method": "btree",
1161+ "with": {}
1162+ }
1163+ },
1164+ "foreignKeys": {},
1165+ "compositePrimaryKeys": {},
1166+ "uniqueConstraints": {},
1167+ "policies": {},
1168+ "checkConstraints": {},
1169+ "isRLSEnabled": false
1170+ },
1171+ "public.tool_policy": {
1172+ "name": "tool_policy",
1173+ "schema": "",
1174+ "columns": {
1175+ "id": {
1176+ "name": "id",
1177+ "type": "varchar(255)",
1178+ "primaryKey": false,
1179+ "notNull": true
1180+ },
1181+ "pattern": {
1182+ "name": "pattern",
1183+ "type": "text",
1184+ "primaryKey": false,
1185+ "notNull": true
1186+ },
1187+ "action": {
1188+ "name": "action",
1189+ "type": "text",
1190+ "primaryKey": false,
1191+ "notNull": true
1192+ },
1193+ "position": {
1194+ "name": "position",
1195+ "type": "text",
1196+ "primaryKey": false,
1197+ "notNull": true
1198+ },
1199+ "created_at": {
1200+ "name": "created_at",
1201+ "type": "timestamp",
1202+ "primaryKey": false,
1203+ "notNull": true
1204+ },
1205+ "updated_at": {
1206+ "name": "updated_at",
1207+ "type": "timestamp",
1208+ "primaryKey": false,
1209+ "notNull": true
1210+ },
1211+ "row_id": {
1212+ "name": "row_id",
1213+ "type": "varchar(255)",
1214+ "primaryKey": true,
1215+ "notNull": true
1216+ },
1217+ "tenant": {
1218+ "name": "tenant",
1219+ "type": "varchar(255)",
1220+ "primaryKey": false,
1221+ "notNull": true
1222+ },
1223+ "owner": {
1224+ "name": "owner",
1225+ "type": "varchar(255)",
1226+ "primaryKey": false,
1227+ "notNull": true
1228+ },
1229+ "subject": {
1230+ "name": "subject",
1231+ "type": "varchar(255)",
1232+ "primaryKey": false,
1233+ "notNull": true
1234+ }
1235+ },
1236+ "indexes": {
1237+ "tool_policy_uidx": {
1238+ "name": "tool_policy_uidx",
1239+ "columns": [
1240+ {
1241+ "expression": "tenant",
1242+ "isExpression": false,
1243+ "asc": true,
1244+ "nulls": "last"
1245+ },
1246+ {
1247+ "expression": "owner",
1248+ "isExpression": false,
1249+ "asc": true,
1250+ "nulls": "last"
1251+ },
1252+ {
1253+ "expression": "subject",
1254+ "isExpression": false,
1255+ "asc": true,
1256+ "nulls": "last"
1257+ },
1258+ {
1259+ "expression": "id",
1260+ "isExpression": false,
1261+ "asc": true,
1262+ "nulls": "last"
1263+ }
1264+ ],
1265+ "isUnique": true,
1266+ "concurrently": false,
1267+ "method": "btree",
1268+ "with": {}
1269+ }
1270+ },
1271+ "foreignKeys": {},
1272+ "compositePrimaryKeys": {},
1273+ "uniqueConstraints": {},
1274+ "policies": {},
1275+ "checkConstraints": {},
1276+ "isRLSEnabled": false
1277+ }
1278+ },
1279+ "enums": {},
1280+ "schemas": {},
1281+ "sequences": {},
1282+ "roles": {},
1283+ "policies": {},
1284+ "views": {},
1285+ "_meta": {
1286+ "columns": {},
1287+ "schemas": {},
1288+ "tables": {}
1289+ }
1290+}
apps/cloud/drizzle/meta/_journal.json+9−2
@@ -61,8 +61,15 @@ · 60 unmodified lines
6161 {
6262 "idx": 8,
6363 "version": "7",
64− "when": 1782957914937,
65− "tag": "0008_true_lake",
64+ "when": 1783025157232,
65+ "tag": "0008_slow_mephisto",
66+ "breakpoints": true
67+ },
68+ {
69+ "idx": 9,
70+ "version": "7",
71+ "when": 1783025157233,
72+ "tag": "0009_true_lake",
6673 "breakpoints": true
6774 }
6875 ]
apps/cloud/scripts/code-migrations/gc-dead-dcr-oauth-clients.ts+174−0
@@ -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+};
apps/cloud/scripts/code-migrations/index.ts+4−0
@@ -1,5 +1,6 @@
11 import type { CodeMigration } from "./runner";
22 import { googleOpenApiR2BlobMigration } from "./google-openapi-r2-blobs";
3+import { gcDeadDcrOAuthClientsMigration } from "./gc-dead-dcr-oauth-clients";
34
45 export interface CloudCodeMigrationRegistryOptions {
56 readonly r2Bucket?: string;
@@ -11,6 +12,9 @@ export const cloudCodeMigrations = ({ · 5 unmodified lines
1112 limit,
1213 }: CloudCodeMigrationRegistryOptions): readonly CodeMigration[] => [
1314 ...(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,
1418 ];
1519
1620 export { runCodeMigrations } from "./runner";
apps/cloud/src/db/executor-schema.ts+1−0
@@ -85,6 +85,7 @@ export const oauth_client = pgTable( · 84 unmodified lines
8585 resource: text("resource"),
8686 origin_kind: text("origin_kind"),
8787 origin_integration: text("origin_integration"),
88+ origin_issuer: text("origin_issuer"),
8889 created_at: timestamp("created_at").notNull(),
8990 row_id: varchar("row_id", { length: 255 })
9091 .primaryKey()
apps/cloud/src/db/gc-dead-dcr-oauth-clients-code-migration.test.ts+207−0
@@ -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+});
apps/cloud/src/engine/execution-gate.ts+185−0
@@ -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+};
apps/cloud/src/engine/execution-limit-messages.ts+11−0
@@ -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.";
apps/cloud/src/engine/execution-rate-limit.ts+220−0
@@ -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+};
apps/cloud/src/engine/execution-stack-metered.ts+30−9
@@ -28,19 +28,40 @@ import { · 27 unmodified lines
2828 import { AutumnService } from "../extensions/billing/service";
2929 import type { DbService } from "../db/db";
3030 import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
31+import { makeExecutionLimitGate } from "./execution-gate";
32+import { makeCloudExecutionRateLimiter } from "./execution-rate-limit";
3133 import { withExecutionUsageTracking } from "./execution-usage";
3234
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).
3645 export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> =
3746 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+ }),
4465 );
4566
4667 /**
apps/cloud/src/env-augment.d.ts+14−0
@@ -32,6 +32,20 @@ declare global { · 31 unmodified lines
3232 // ON; the test workers set "true" so fixtures can reach localhost.
3333 ALLOW_LOCAL_NETWORK?: string;
3434
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+
3549 // Billing
3650 AUTUMN_SECRET_KEY?: string;
3751 /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */
apps/cloud/src/extensions/billing/service.ts+14−1
@@ -22,6 +22,9 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ · 21 unmodified lines
2222
2323 export type IAutumnService = Readonly<{
2424 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>;
2528 /**
2629 * Fire-and-forget-safe execution usage tracker. Errors are caught and
2730 * logged; the returned Effect never fails. Callers typically
@@ -44,6 +47,7 @@ const make = Effect.sync(() => { · 16 unmodified lines
4447 );
4548 return {
4649 use: () => notConfigured,
50+ checkExecutionBalance: () => notConfigured,
4751 trackExecution: () => Effect.void,
4852 } satisfies IAutumnService;
4953 }
@@ -80,7 +84,16 @@ const make = Effect.sync(() => { · 30 unmodified lines
8084 );
8185 }).pipe(Effect.withSpan("autumn.trackExecution"));
8286
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;
8497 });
8598
8699 export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(
apps/cloud/src/server.ts+5−0
@@ -55,6 +55,11 @@ export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( · 54 unmodified lines
5555 // (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it.
5656 export class McpSessionDO extends DurableObject {}
5757
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+
5863 // ---------------------------------------------------------------------------
5964 // Worker fetch handler
6065 //
apps/cloud/src/web/components/support-options.tsx+1−1
@@ -13,7 +13,7 @@ const supportLinks = [ · 12 unmodified lines
1313 { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark },
1414 {
1515 label: "GitHub Issues",
16− href: "https://github.com/RhysSullivan/executor/issues",
16+ href: "https://github.com/UsefulSoftwareCo/executor/issues",
1717 icon: GitHubMark,
1818 },
1919 { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark },
apps/cloud/wrangler.jsonc+11−0
@@ -22,6 +22,13 @@ · 21 unmodified lines
2222 "name": "MCP_SESSION",
2323 "class_name": "McpSessionDOSqlite",
2424 },
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+ },
2532 ],
2633 },
2734 // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which
@@ -42,6 +49,10 @@ · 14 unmodified lines
4249 "tag": "v2",
4350 "new_sqlite_classes": ["McpSessionDOSqlite"],
4451 },
52+ {
53+ "tag": "v3",
54+ "new_sqlite_classes": ["ExecutionRateLimiterDO"],
55+ },
4556 ],
4657 "services": [
4758 {
apps/desktop/electron-builder.config.ts+1−1
@@ -57,7 +57,7 @@ const config: Configuration = { · 56 unmodified lines
5757 },
5858 publish: {
5959 provider: "github",
60− owner: "RhysSullivan",
60+ owner: "UsefulSoftwareCo",
6161 repo: "executor",
6262 },
6363 };
apps/desktop/electron.vite.config.ts+3−1
@@ -53,7 +53,9 @@ export default defineConfig({ · 52 unmodified lines
5353 "import.meta.env.VITE_APP_VERSION": JSON.stringify(
5454 process.env.npm_package_version ?? "0.0.0",
5555 ),
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+ ),
5759 "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify(
5860 process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh",
5961 ),
apps/desktop/package.json+1−1
@@ -2,7 +2,7 @@ · 1 unmodified line
22 "name": "@executor-js/desktop",
33 "version": "1.5.27",
44 "private": true,
5− "homepage": "https://github.com/RhysSullivan/executor",
5+ "homepage": "https://github.com/UsefulSoftwareCo/executor",
66 "license": "MIT",
77 "type": "module",
88 "main": "./out/main/index.js",
apps/desktop/src/main/diagnostics.ts+1−1
@@ -259,7 +259,7 @@ export const reportAProblem = async () => { · 258 unmodified lines
259259 "",
260260 "_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._",
261261 ].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");
263263 url.searchParams.set("title", "[desktop] ");
264264 url.searchParams.set("body", body);
265265 await shell.openExternal(url.toString());
apps/docs/docs.json+2−2
@@ -45,7 +45,7 @@ · 44 unmodified lines
4545 "links": [
4646 {
4747 "label": "GitHub",
48− "href": "https://github.com/RhysSullivan/executor"
48+ "href": "https://github.com/UsefulSoftwareCo/executor"
4949 }
5050 ],
5151 "primary": {
@@ -56,7 +56,7 @@ · 4 unmodified lines
5656 },
5757 "footer": {
5858 "socials": {
59− "github": "https://github.com/RhysSullivan/executor"
59+ "github": "https://github.com/UsefulSoftwareCo/executor"
6060 }
6161 }
6262 }
apps/docs/hosted/cloudflare.mdx+1−1
@@ -15,7 +15,7 @@ and you manage members and credentials in Cloudflare Access rather than in the a · 14 unmodified lines
1515 ## Prerequisites
1616
1717 - 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
1919 dependencies installed (`bun install`).
2020
2121 ## Deploy
apps/docs/hosted/docker.mdx+3−3
@@ -17,10 +17,10 @@ docker run -d \ · 16 unmodified lines
1717 --name executor-selfhost \
1818 -p 4788:4788 \
1919 -v executor-data:/data \
20− ghcr.io/rhyssullivan/executor-selfhost:latest
20+ ghcr.io/usefulsoftwareco/executor-selfhost:latest
2121 ```
2222
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),
2424 from `apps/host-selfhost`:
2525
2626 ```bash
@@ -81,7 +81,7 @@ docker run -d \ · 54 unmodified lines
8181 -p 4788:4788 \
8282 -v executor-data:/data \
8383 -e EXECUTOR_WEB_BASE_URL=https://executor.example.com \
84− ghcr.io/rhyssullivan/executor-selfhost:latest
84+ ghcr.io/usefulsoftwareco/executor-selfhost:latest
8585 ```
8686
8787 For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL`
apps/docs/index.mdx+1−1
@@ -54,7 +54,7 @@ Once you know which form I want: · 53 unmodified lines
5454 3. Once the tools are available, help me add my first integration and get one tool working end to end.
5555
5656 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
5858 ```
5959
6060 ## Concepts
apps/host-cloudflare/vite.config.ts+3−1
@@ -50,7 +50,9 @@ export default defineConfig({ · 49 unmodified lines
5050 // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm,
5151 // so the update card links to the upgrade guide instead of a command.
5252 "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+ ),
5456 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"),
5557 },
5658 server: {
apps/host-selfhost/Dockerfile+1−1
@@ -23,7 +23,7 @@ RUN cd apps/host-selfhost && bun run build · 22 unmodified lines
2323
2424 FROM gcr.io/distroless/cc-debian12 AS runtime
2525 WORKDIR /app
26−LABEL org.opencontainers.image.source="https://github.com/RhysSullivan/executor" \
26+LABEL org.opencontainers.image.source="https://github.com/UsefulSoftwareCo/executor" \
2727 org.opencontainers.image.description="Single-container self-hosted Executor" \
2828 org.opencontainers.image.licenses="MIT"
2929 ENV NODE_ENV=production \
apps/host-selfhost/README.md+1−1
@@ -14,7 +14,7 @@ docker run -d \ · 13 unmodified lines
1414 --name executor-selfhost \
1515 -p 4788:4788 \
1616 -v executor-data:/data \
17− ghcr.io/rhyssullivan/executor-selfhost:latest
17+ ghcr.io/usefulsoftwareco/executor-selfhost:latest
1818 ```
1919
2020 Or build from a repository clone:
apps/host-selfhost/vite.config.ts+3−1
@@ -218,7 +218,9 @@ export default defineConfig({ · 217 unmodified lines
218218 // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not
219219 // npm, so the update card links to the upgrade guide instead of a command.
220220 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"),
221− "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"),
221+ "import.meta.env.VITE_GITHUB_URL": JSON.stringify(
222+ "https://github.com/UsefulSoftwareCo/executor",
223+ ),
222224 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
223225 },
224226 server: {
apps/local/drizzle/0003_graceful_the_fallen.sql+1−0
@@ -0,0 +1 @@
1+ALTER TABLE `oauth_client` ADD `origin_issuer` text;
apps/local/drizzle/meta/0003_snapshot.json+920−0
@@ -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+}
apps/local/drizzle/meta/_journal.json+7−0
@@ -22,6 +22,13 @@ · 21 unmodified lines
2222 "when": 1782150869757,
2323 "tag": "0002_empty_rictor",
2424 "breakpoints": true
25+ },
26+ {
27+ "idx": 3,
28+ "version": "6",
29+ "when": 1783025153772,
30+ "tag": "0003_graceful_the_fallen",
31+ "breakpoints": true
2532 }
2633 ]
2734 }
apps/local/src/db/data-migrations.ts+9−1
@@ -5,7 +5,12 @@ · 4 unmodified lines
55 // renamed.
66 // ---------------------------------------------------------------------------
77
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";
914 import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
1015 import {
1116 openApiOutputSchemaDataMigration,
@@ -37,4 +42,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 25 unmodified lines
3742 openApiSpecBlobDataMigration,
3843 graphqlIntrospectionBlobDataMigration,
3944 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,
4048 ];
apps/local/src/db/executor-schema.ts+1−0
@@ -75,6 +75,7 @@ export const oauth_client = sqliteTable( · 74 unmodified lines
7575 resource: text("resource"),
7676 origin_kind: text("origin_kind"),
7777 origin_integration: text("origin_integration"),
78+ origin_issuer: text("origin_issuer"),
7879 created_at: integer("created_at").notNull(),
7980 row_id: text("row_id").primaryKey().notNull(),
8081 tenant: text("tenant").notNull(),
apps/local/src/db/sqlite-fumadb.ts+6−0
@@ -93,6 +93,12 @@ export const createSqliteFumaDb = async <const TTables extends FumaTables>( · 92 unmodified lines
9393 ) {
9494 await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_integration TEXT");
9595 }
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+ }
96102
97103 const { db, fuma } = createExecutorFumaDb(drizzleDb, {
98104 tables: options.tables,
apps/local/vite.config.ts+1−1
@@ -34,7 +34,7 @@ const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; · 33 unmodified lines
3434 const EXECUTOR_GITHUB_URL = (
3535 rootPackage.homepage ??
3636 repositoryUrl ??
37− "https://github.com/RhysSullivan/executor"
37+ "https://github.com/UsefulSoftwareCo/executor"
3838 )
3939 .replace(/^git\+/, "")
4040 .replace(/\.git$/, "");
apps/marketing/src/components/LegalLayout.astro+1−1
@@ -56,7 +56,7 @@ const { title, description, lastUpdated } = Astro.props; · 55 unmodified lines
5656 <a href="/privacy" class="text-ink-faint transition-colors hover:text-ink">Privacy</a>
5757 <a href="/terms" class="text-ink-faint transition-colors hover:text-ink">Terms</a>
5858 <a
59− href="https://github.com/RhysSullivan/executor"
59+ href="https://github.com/UsefulSoftwareCo/executor"
6060 class="text-ink-faint transition-colors hover:text-ink"
6161 >
6262 GitHub
apps/marketing/src/layouts/BlogPostLayout.astro+1−1
@@ -21,7 +21,7 @@ const { · 20 unmodified lines
2121 authorAvatar,
2222 } = Astro.props;
2323
24−const GH = "https://github.com/RhysSullivan/executor";
24+const GH = "https://github.com/UsefulSoftwareCo/executor";
2525 const authorX = `https://x.com/${authorHandle}`;
2626
2727 const formattedDate = date.toLocaleDateString("en-US", {
apps/marketing/src/lib/github.ts+1−1
@@ -11,7 +11,7 @@ · 10 unmodified lines
1111 // - resolve to null on any failure, in which case the nav simply drops the
1212 // count and keeps the link.
1313
14−const REPO = "RhysSullivan/executor";
14+const REPO = "UsefulSoftwareCo/executor";
1515
1616 export const GITHUB_REPO_URL = `https://github.com/${REPO}`;
1717
apps/marketing/src/pages/blog/index.astro+1−1
@@ -3,7 +3,7 @@ export const prerender = true; · 2 unmodified lines
33 import Layout from "../../layouts/Layout.astro";
44 import { getCollection } from "astro:content";
55
6−const GH = "https://github.com/RhysSullivan/executor";
6+const GH = "https://github.com/UsefulSoftwareCo/executor";
77
88 const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
99 (a, b) => b.data.date.getTime() - a.data.date.getTime(),
apps/marketing/src/pages/index.astro+2−2
@@ -8,7 +8,7 @@ import { SelfHostContactModal } from "../components/self-host-contact-modal"; · 7 unmodified lines
88 import ycBackedBy from "../assets/yc-backed-by.svg?url";
99 import { getStars, formatStars } from "../lib/github";
1010
11−const GH = "https://github.com/RhysSullivan/executor";
11+const GH = "https://github.com/UsefulSoftwareCo/executor";
1212 // Cloud sign-in. Same origin in prod: the cloud Worker owns executor.sh and
1313 // /api/* is app-owned (apps/cloud/src/app-paths.ts), so this hits the WorkOS
1414 // 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
5050 3. Once the tools are available, help me add my first integration and get one tool working end to end.
5151
5252 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`;
5454 ---
5555
5656 <Layout>
bun.lock+4−2
@@ -344,7 +344,7 @@ · 343 unmodified lines
344344 "version": "0.0.24",
345345 "dependencies": {
346346 "@executor-js/api": "workspace:*",
347− "@executor-js/emulate": "^0.10.0",
347+ "@executor-js/emulate": "^0.13.2",
348348 "@executor-js/mcporter": "^0.11.4",
349349 "@executor-js/plugin-google": "workspace:*",
350350 "@executor-js/plugin-graphql": "workspace:*",
@@ -583,6 +583,7 @@ · 232 unmodified lines
583583 "@standard-schema/spec": "^1.1.0",
584584 "fractional-indexing": "^3.2.0",
585585 "oauth4webapi": "^3.8.5",
586+ "tldts": "^7.0.28",
586587 },
587588 "devDependencies": {
588589 "@effect/atom-react": "catalog:",
@@ -665,6 +666,7 @@ · 76 unmodified lines
665666 "@types/node": "catalog:",
666667 "bun-types": "catalog:",
667668 "typescript": "catalog:",
669+ "urlpattern-polyfill": "^10.1.0",
668670 "vitest": "catalog:",
669671 },
670672 },
@@ -1743,7 +1745,7 @@ · 1072 unmodified lines
17431745
17441746 "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"],
17451747
1746− "@executor-js/emulate": ["@executor-js/emulate@0.10.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-GE1+XDQ4FJt4ZDrwNjuUqUEG1WaH06UE12ME/xJdcCNbsa6EE6SA+i8onVJQ5Dr7DbUveU37E4djLTiAcceLPw=="],
1748+ "@executor-js/emulate": ["@executor-js/emulate@0.13.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-ikmzZgBrRnmJgKKOW9TE2KCCdenKtihx2nWW/mMMmugn3UlVJsxuIxp6jv0BEo4GZcY8Ve2oka6UEKl+gBKRZg=="],
17471749
17481750 "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"],
17491751
e2e/cloud/dcr-root-domain-isolation.test.ts+176−0
@@ -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+);
e2e/cloud/mcp-execution-limits.test.ts+200−0
@@ -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+);
e2e/package.json+1−1
@@ -22,7 +22,7 @@ · 21 unmodified lines
2222 },
2323 "dependencies": {
2424 "@executor-js/api": "workspace:*",
25− "@executor-js/emulate": "^0.10.0",
25+ "@executor-js/emulate": "^0.13.2",
2626 "@executor-js/mcporter": "^0.11.4",
2727 "@executor-js/plugin-google": "workspace:*",
2828 "@executor-js/plugin-graphql": "workspace:*",
e2e/setup/autumn-plans.ts+18−0
@@ -49,3 +49,21 @@ const toSeed = (plan: AtmnPlan): AutumnSeedPlan => ({ · 48 unmodified lines
4949 export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) =>
5050 toSeed(p as AtmnPlan),
5151 );
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+})();
e2e/setup/cloud.boot.ts+7−0
@@ -12,6 +12,7 @@ import { createEmulator } from "@executor-js/emulate"; · 11 unmodified lines
1212
1313 import { bootProcesses, waitForHttp } from "./boot";
1414 import { AUTUMN_PLAN_SEED } from "./autumn-plans";
15+import { E2E_EXECUTION_RATE_LIMIT } from "./execution-limits";
1516
1617 export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));
1718
@@ -90,6 +91,12 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 72 unmodified lines
9091 MCP_AUTHKIT_DOMAIN: workosUrl,
9192 MCP_RESOURCE_ORIGIN: options.publicUrl,
9293 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),
93100 // Throwaway PGlite on its own port + dir so it never fights `bun dev`.
94101 DEV_DB_PORT: String(options.dbPort),
95102 DEV_DB_PATH: dbPath,
e2e/setup/execution-limits.ts+14−0
@@ -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;
e2e/src/surfaces/autumn.ts+123−0
@@ -9,6 +9,8 @@ · 8 unmodified lines
99 // so the contract has to be pinned where the usage is read.
1010 import { Effect, Schedule } from "effect";
1111
12+import { DEFAULT_PLAN_EXECUTIONS_INCLUDED } from "../../setup/autumn-plans";
13+
1214 /** One usage event Autumn recorded: a `track` of `value` units of `featureId`
1315 * for `customerId` (the organization the execution ran under). */
1416 export interface UsageEvent {
@@ -24,6 +26,33 @@ export interface UsageQuery { · 9 unmodified lines
2426 readonly featureId: string;
2527 }
2628
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+
2756 export interface AutumnSurface {
2857 /** One-shot read of the matching usage events from the ledger. */
2958 readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>;
@@ -41,6 +70,20 @@ export interface AutumnSurface { · 11 unmodified lines
4170 * the billing backend becomes consistent. The `sessionId` is the last path
4271 * segment of the hosted checkout URL the browser was sent to. */
4372 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>;
4487 }
4588
4689 export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => {
@@ -90,9 +133,89 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { · 43 unmodified lines
90133 }
91134 });
92135
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+
93212 return {
94213 usageEvents,
95214 settleCheckout,
215+ exhaustExecutions,
216+ armFault,
217+ clearFaults,
218+ ledgerFor,
96219 expectUsage: (query) =>
97220 usageEvents(query).pipe(
98221 Effect.filterOrFail(
package.json+3−3
@@ -11,14 +11,14 @@ · 10 unmodified lines
1111 "executor",
1212 "local-first"
1313 ],
14− "homepage": "https://github.com/RhysSullivan/executor",
14+ "homepage": "https://github.com/UsefulSoftwareCo/executor",
1515 "bugs": {
16− "url": "https://github.com/RhysSullivan/executor/issues"
16+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
1717 },
1818 "license": "MIT",
1919 "repository": {
2020 "type": "git",
21− "url": "git+https://github.com/RhysSullivan/executor.git"
21+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git"
2222 },
2323 "workspaces": [
2424 "packages/*/*",
packages/app/vite.config.ts+3−1
@@ -13,7 +13,9 @@ export default defineConfig({ · 12 unmodified lines
1313 plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })],
1414 define: {
1515 "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+ ),
1719 "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),
1820 },
1921 });
packages/core/api/src/handlers/oauth.ts+2−0
@@ -103,6 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 102 unmodified lines
103103 clientId: payload.clientId,
104104 clientSecret: payload.clientSecret,
105105 resource: payload.resource ?? null,
106+ origin: { kind: "manual", integration: payload.originIntegration ?? null },
106107 });
107108 return { client };
108109 }),
@@ -115,6 +116,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 6 unmodified lines
115116 const client = yield* executor.oauth.registerDynamicClient({
116117 owner: payload.owner,
117118 slug: payload.slug,
119+ issuer: payload.issuer ?? null,
118120 registrationEndpoint: payload.registrationEndpoint,
119121 authorizationUrl: payload.authorizationUrl,
120122 tokenUrl: payload.tokenUrl,
packages/core/api/src/oauth/api.ts+5−0
@@ -65,6 +65,9 @@ const CreateClientPayload = Schema.Struct({ · 64 unmodified lines
6565 clientId: Schema.String,
6666 clientSecret: Schema.String,
6767 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)),
6871 });
6972
7073 const CreateClientResponse = Schema.Struct({
@@ -81,6 +84,7 @@ const CreateClientResponse = Schema.Struct({ · 10 unmodified lines
8184 const RegisterDynamicPayload = Schema.Struct({
8285 owner: Owner,
8386 slug: OAuthClientSlug,
87+ issuer: Schema.optional(Schema.NullOr(Schema.String)),
8488 registrationEndpoint: Schema.String,
8589 authorizationUrl: Schema.String,
8690 tokenUrl: Schema.String,
@@ -203,6 +207,7 @@ const ProbePayload = Schema.Struct({ · 116 unmodified lines
203207 });
204208
205209 const ProbeResponse = Schema.Struct({
210+ issuer: Schema.optional(Schema.NullOr(Schema.String)),
206211 authorizationUrl: Schema.String,
207212 tokenUrl: Schema.String,
208213 resource: Schema.optional(Schema.NullOr(Schema.String)),
packages/core/cli/package.json+3−3
@@ -2,14 +2,14 @@ · 1 unmodified line
22 "name": "@executor-js/cli",
33 "version": "0.2.34",
44 "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",
66 "bugs": {
7− "url": "https://github.com/RhysSullivan/executor/issues"
7+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
88 },
99 "license": "MIT",
1010 "repository": {
1111 "type": "git",
12− "url": "git+https://github.com/RhysSullivan/executor.git",
12+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1313 "directory": "packages/core/cli"
1414 },
1515 "bin": {
packages/core/config/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/config",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/core/config"
1313 },
1414 "files": [
packages/core/execution/README.md+1−1
@@ -126,7 +126,7 @@ import { createExecutionEngine } from "@executor-js/execution/core"; · 125 unmodified lines
126126
127127 ## Status
128128
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).
130130
131131 ## License
132132
packages/core/execution/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/execution",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/core/execution"
1313 },
1414 "files": [
packages/core/fumadb/package.json+1−1
@@ -4,7 +4,7 @@ · 3 unmodified lines
44 "version": "1.5.7",
55 "repository": {
66 "type": "git",
7− "url": "git+https://github.com/RhysSullivan/executor.git",
7+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
88 "directory": "packages/core/fumadb"
99 },
1010 "author": "Fuma Nama",
packages/core/integrations-registry/package.json+3−3
@@ -2,14 +2,14 @@ · 1 unmodified line
22 "name": "@executor-js/integrations-registry",
33 "version": "0.1.0",
44 "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",
66 "bugs": {
7− "url": "https://github.com/RhysSullivan/executor/issues"
7+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
88 },
99 "license": "MIT",
1010 "repository": {
1111 "type": "git",
12− "url": "git+https://github.com/RhysSullivan/executor.git",
12+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1313 "directory": "packages/core/integrations-registry"
1414 },
1515 "files": [
packages/core/sdk/README.md+2−2
@@ -126,7 +126,7 @@ console.log(await executor.secrets.get("api-token")); // "sk_live_xxx" · 125 unmodified lines
126126 await executor.close();
127127 ```
128128
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.
130130
131131 ## Plugins
132132
@@ -165,7 +165,7 @@ Plugins that need a token — HTTP-backed sources, OAuth flows, etc. — accept · 32 unmodified lines
165165
166166 ## Status
167167
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.
169169
170170 ## License
171171
packages/core/sdk/package.json+5−4
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/sdk",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/core/sdk"
1313 },
1414 "files": [
@@ -96,7 +96,8 @@ · 81 unmodified lines
9696 "@executor-js/fumadb": "workspace:*",
9797 "@standard-schema/spec": "^1.1.0",
9898 "fractional-indexing": "^3.2.0",
99− "oauth4webapi": "^3.8.5"
99+ "oauth4webapi": "^3.8.5",
100+ "tldts": "^7.0.28"
100101 },
101102 "devDependencies": {
102103 "@effect/atom-react": "catalog:",
packages/core/sdk/src/core-schema.ts+8−0
@@ -223,6 +223,14 @@ export const coreTables = defineTables({ · 222 unmodified lines
223223 // "manual" by the service layer.
224224 origin_kind: nullableTextColumn("origin_kind"),
225225 origin_integration: nullableTextColumn("origin_integration"),
226+ // Authorization-server issuer that owns a DCR client, keying per-AS reuse.
227+ // For a NEW DCR registration this is the DISCOVERED OIDC issuer (real
228+ // information from the AS metadata, which can legitimately differ from what
229+ // token_url would suggest). For a BACKFILLED legacy row it is instead a
230+ // derived registrable-origin of token_url (a cache of the pure
231+ // `registrableOriginOfUrl`, since no discovered issuer was ever recorded).
232+ // Null for manual clients and legacy rows not yet backfilled.
233+ origin_issuer: nullableTextColumn("origin_issuer"),
226234 created_at: dateColumn("created_at"),
227235 },
228236 ["tenant", "owner", "subject", "slug"],
packages/core/sdk/src/core-tools.ts+14−0
@@ -243,6 +243,9 @@ const OAuthCreateClientInput = Schema.Struct({ · 242 unmodified lines
243243 grant: OAuthGrantSchema,
244244 clientId: Schema.String,
245245 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)),
246249 });
247250 // Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret
248251 // 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
269272 const OAuthRegisterDynamicInput = Schema.Struct({
270273 owner: OwnerSchema,
271274 slug: Schema.String,
275+ issuer: Schema.optional(Schema.NullOr(Schema.String)),
272276 registrationEndpoint: Schema.String,
273277 authorizationUrl: Schema.String,
274278 tokenUrl: Schema.String,
@@ -287,6 +291,7 @@ const OAuthProbeInput = Schema.Struct({ · 12 unmodified lines
287291 url: Schema.String,
288292 });
289293 const OAuthProbeOutput = Schema.Struct({
294+ issuer: Schema.optional(Schema.NullOr(Schema.String)),
290295 authorizationUrl: Schema.String,
291296 tokenUrl: Schema.String,
292297 resource: Schema.optional(Schema.NullOr(Schema.String)),
@@ -711,6 +716,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 418 unmodified lines
711716 // `oauth.clients.createHandoff`.
712717 clientSecret: "",
713718 resource: input.resource ?? null,
719+ origin: {
720+ kind: "manual",
721+ integration:
722+ input.originIntegration == null
723+ ? null
724+ : IntegrationSlug.make(input.originIntegration),
725+ },
714726 }),
715727 (client) => ({ client: String(client) }),
716728 ),
@@ -748,6 +760,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 31 unmodified lines
748760 ctx.oauth.registerDynamicClient({
749761 owner: input.owner as Owner,
750762 slug: OAuthClientSlug.make(input.slug),
763+ issuer: input.issuer ?? null,
751764 registrationEndpoint: input.registrationEndpoint,
752765 authorizationUrl: input.authorizationUrl,
753766 tokenUrl: input.tokenUrl,
@@ -789,6 +802,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 35 unmodified lines
789802 outputSchema: OAuthProbeOutputStd,
790803 execute: (input: typeof OAuthProbeInput.Type, { ctx }) =>
791804 Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({
805+ issuer: result.issuer ?? null,
792806 authorizationUrl: result.authorizationUrl,
793807 tokenUrl: result.tokenUrl,
794808 resource: result.resource ?? null,
packages/core/sdk/src/index.ts+13−0
@@ -386,6 +386,19 @@ export { · 385 unmodified lines
386386 runSqliteConfigBlobMigration,
387387 type SqliteConfigBlobMigrationOptions,
388388 } from "./sqlite-config-blob-migration";
389+// DCR oauth_client GC + issuer backfill: shared classification predicates and
390+// the libSQL boot-migration body (issue #1120, Part C).
391+export {
392+ classifyOAuthClientGc,
393+ isDcrClassifiedRow,
394+ registrableOriginOfUrl,
395+ type OAuthClientGcDecision,
396+ type OAuthClientGcRow,
397+} from "./oauth-gc";
398+export {
399+ oauthClientGcSqliteMigration,
400+ runSqliteOAuthClientGcMigration,
401+} from "./sqlite-oauth-client-gc-migration";
389402 export {
390403 authToolFailure,
391404 type AuthToolFailureCode,
packages/core/sdk/src/oauth-client.ts+14−1
@@ -64,7 +64,15 @@ export interface OAuthClient { · 63 unmodified lines
6464 }
6565
6666 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+ }
6876 | {
6977 readonly kind: "dynamic_client_registration";
7078 readonly integration?: IntegrationSlug | null;
@@ -72,6 +80,7 @@ export type OAuthClientOrigin = · 1 unmodified line
7280
7381 export type CreateOAuthClientInput = OAuthClient & {
7482 readonly origin?: OAuthClientOrigin;
83+ readonly originIssuer?: string | null;
7584 };
7685
7786 /** Metadata-only projection of a registered client for listing in the UI.
@@ -134,6 +143,8 @@ export interface OAuthProbeInput { · 56 unmodified lines
134143 }
135144
136145 export interface OAuthProbeResult {
146+ /** RFC 8414 authorization-server issuer. Used to key DCR clients per AS. */
147+ readonly issuer?: string | null;
137148 readonly authorizationUrl: string;
138149 readonly tokenUrl: string;
139150 /** RFC 8707 resource indicator discovered from protected-resource metadata.
@@ -157,6 +168,8 @@ export interface OAuthProbeResult { · 17 unmodified lines
157168 export interface RegisterDynamicClientInput {
158169 readonly owner: Owner;
159170 readonly slug: OAuthClientSlug;
171+ /** RFC 8414 authorization-server issuer, when discovered before DCR. */
172+ readonly issuer?: string | null;
160173 /** RFC 7591 registration endpoint advertised by the authorization server. */
161174 readonly registrationEndpoint: string;
162175 readonly authorizationUrl: string;
packages/core/sdk/src/oauth-gc.test.ts+221−0
@@ -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+});
packages/core/sdk/src/oauth-gc.ts+156−0
@@ -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+};
packages/core/sdk/src/oauth-list-clients.test.ts+3−1
@@ -91,7 +91,9 @@ describe("oauth.listClients", () => { · 90 unmodified lines
9191 tokenUrl: "https://acme.test/token",
9292 resource: null,
9393 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 },
9597 });
9698 expect(user!.owner).toBe("user");
9799 expect(user!.grant).toBe("client_credentials");
packages/core/sdk/src/oauth-register-dynamic.test.ts+385−3
@@ -25,6 +25,18 @@ const TEMPLATE = AuthTemplateSlug.make("oauth"); · 24 unmodified lines
2525 const CLIENT = OAuthClientSlug.make("acme-dcr");
2626 const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback";
2727
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+
2840 const oauthPlugin = definePlugin(() => ({
2941 id: "acme" as const,
3042 storage: () => ({}),
@@ -56,6 +68,7 @@ describe("oauth.registerDynamicClient", () => { · 25 unmodified lines
5668 // Probe surfaces the registration endpoint + advertised auth methods so
5769 // the caller knows a public client is allowed.
5870 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
71+ expect(probe.issuer).toBe(server.issuerUrl);
5972 expect(probe.registrationEndpoint).toBe(server.registrationEndpoint);
6073 expect(probe.tokenEndpointAuthMethodsSupported).toContain("none");
6174 expect(probe.resource).toBe(server.mcpResourceUrl);
@@ -64,6 +77,7 @@ describe("oauth.registerDynamicClient", () => { · 2 unmodified lines
6477 const slug = yield* executor.oauth.registerDynamicClient({
6578 owner: "org",
6679 slug: CLIENT,
80+ issuer: probe.issuer,
6781 registrationEndpoint: probe.registrationEndpoint!,
6882 authorizationUrl: probe.authorizationUrl,
6983 tokenUrl: probe.tokenUrl,
@@ -74,12 +88,13 @@ describe("oauth.registerDynamicClient", () => { · 4 unmodified lines
7488 redirectUri: FLOW_REDIRECT_URI,
7589 originIntegration: INTEG,
7690 });
77− expect(String(slug)).toBe(String(CLIENT));
91+ const expectedSlug = dcrSlugFor(server.issuerUrl);
92+ expect(String(slug)).toBe(String(expectedSlug));
7893
7994 // The minted client appears in listClients with a server-issued
8095 // client_id and NO secret ever projected.
8196 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));
8398 expect(minted).toBeDefined();
8499 expect(minted!.owner).toBe("org");
85100 expect(minted!.grant).toBe("authorization_code");
@@ -98,7 +113,7 @@ describe("oauth.registerDynamicClient", () => { · 12 unmodified lines
98113 // PKCE flow with NO client_secret on the token exchange.
99114 const started = yield* executor.oauth.start({
100115 owner: "org",
101− client: CLIENT,
116+ client: expectedSlug,
102117 clientOwner: "org",
103118 name: ConnectionName.make("main"),
104119 integration: INTEG,
@@ -145,6 +160,373 @@ describe("oauth.registerDynamicClient", () => { · 40 unmodified lines
145160 ),
146161 );
147162
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+
148530 // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only
149531 // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN
150532 // origin trips `invalid_redirect_uri`. The failure must explain the loopback
packages/core/sdk/src/oauth-service.ts+206−18
@@ -70,6 +70,7 @@ import { · 69 unmodified lines
7070 type OAuthEndpointUrlPolicy,
7171 } from "./oauth-helpers";
7272 import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth";
73+import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc";
7374
7475 /** Connection-minting input for the OAuth flow — extends a connection create
7576 * with the OAuth lifecycle fields (client slug, refresh material, expiry,
@@ -239,6 +240,68 @@ const clientOwnerFromPayload = (payload: unknown): Owner | null => { · 163 unmodified lines
239240 const parseGrant = (grant: unknown): OAuthGrant | null =>
240241 grant === "client_credentials" || grant === "authorization_code" ? grant : null;
241242
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+
242305 const parseOAuthClientOrigin = (row: {
243306 readonly slug?: unknown;
244307 readonly grant?: unknown;
@@ -246,26 +309,27 @@ const parseOAuthClientOrigin = (row: { · 1 unmodified line
246309 readonly origin_kind?: unknown;
247310 readonly origin_integration?: unknown;
248311 }): 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)) {
250316 return {
251− kind: "dynamic_client_registration",
317+ kind: "manual",
252318 integration:
253319 row.origin_integration == null
254320 ? null
255321 : IntegrationSlug.make(String(row.origin_integration)),
256322 };
257323 }
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+ };
269333 };
270334
271335 interface LoadedOAuthClient {
@@ -512,11 +576,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 240 unmodified lines
512576 client_secret_item_id: clientSecretItemIdValue,
513577 resource: input.resource ?? null,
514578 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.
515583 origin_integration:
584+ input.origin?.integration == null ? null : String(input.origin.integration),
585+ origin_issuer:
516586 input.origin?.kind === "dynamic_client_registration"
517− ? input.origin.integration == null
518− ? null
519− : String(input.origin.integration)
587+ ? (canonicalIssuerUrl(input.originIssuer) ?? null)
520588 : null,
521589 created_at: now,
522590 }),
@@ -578,10 +646,128 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
578646 ? "none"
579647 : "client_secret_post";
580648
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+
581762 const registerDynamicClient = (
582763 input: RegisterDynamicClientInput,
583764 ): Effect.Effect<OAuthClientSlug, OAuthRegisterDynamicError | StorageFailure> =>
584765 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;
585771 const flowRedirectUri = input.redirectUri ?? redirectUri;
586772 // DCR registers our callback as the client's redirect_uri — fail loudly
587773 // if the executor has none rather than registering a localhost URL.
@@ -633,7 +819,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 45 unmodified lines
633819 // stored client carries no scope set (the integration drives requests).
634820 yield* createClient({
635821 owner: input.owner,
636− slug: input.slug,
822+ slug,
637823 authorizationUrl: input.authorizationUrl,
638824 tokenUrl: input.tokenUrl,
639825 resource: input.resource ?? null,
@@ -644,8 +830,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 4 unmodified lines
644830 kind: "dynamic_client_registration",
645831 integration: input.originIntegration ?? null,
646832 },
833+ originIssuer: issuer,
647834 });
648− return input.slug;
835+ return slug;
649836 });
650837
651838 // -----------------------------------------------------------------------
@@ -1158,6 +1345,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 506 unmodified lines
11581345 });
11591346 }
11601347 return {
1348+ issuer: as.metadata.issuer,
11611349 authorizationUrl: as.metadata.authorization_endpoint,
11621350 tokenUrl: as.metadata.token_endpoint,
11631351 resource: resource?.metadata.resource ?? null,
packages/core/sdk/src/shared.ts+1−0
@@ -127,6 +127,7 @@ export { · 126 unmodified lines
127127 type OAuthGrant,
128128 type OAuthAuthentication,
129129 type OAuthClient,
130+ type OAuthClientOrigin,
130131 type OAuthClientSummary,
131132 type CreateOAuthClientInput,
132133 type RegisterDynamicClientInput,
packages/core/sdk/src/sqlite-oauth-client-gc-migration.test.ts+351−0
@@ -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+});
packages/core/sdk/src/sqlite-oauth-client-gc-migration.ts+187−0
@@ -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+};
packages/core/vite-plugin/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/vite-plugin",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/core/vite-plugin"
1313 },
1414 "files": [
packages/hosts/cloudflare/package.json+1−0
@@ -37,6 +37,7 @@ · 36 unmodified lines
3737 "@types/node": "catalog:",
3838 "bun-types": "catalog:",
3939 "typescript": "catalog:",
40+ "urlpattern-polyfill": "^10.1.0",
4041 "vitest": "catalog:"
4142 }
4243 }
packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts+360−0
@@ -0,0 +1,360 @@
1+import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2+import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp";
3+import { Effect, Option, Schema } from "effect";
4+
5+const KEEPALIVE_INTERVAL_MS = 25_000;
6+const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
7+
8+type FakeWebSocket = EventTarget & {
9+ accepted: boolean;
10+ closeCode: number | undefined;
11+ closeReason: string | undefined;
12+ accept: () => void;
13+ close: (code?: number, reason?: string) => void;
14+};
15+
16+type FakeAgentStub = {
17+ readonly setName: (name: string, props?: unknown) => Promise<void>;
18+ readonly getInitializeRequest: () => Promise<unknown>;
19+ readonly fetch: (request: Request) => Promise<{ readonly webSocket: FakeWebSocket }>;
20+};
21+
22+type RotationLog = {
23+ readonly event: "sse_max_age_close";
24+ readonly sessionId: string;
25+ readonly variant: "streamable-get" | "streamable-post" | "legacy-sse";
26+ readonly ageMs: number;
27+ readonly pendingBytes: number;
28+};
29+
30+const encoder = new TextEncoder();
31+const RotationLogEvent = Schema.Struct({
32+ ageMs: Schema.Number,
33+ event: Schema.Literal("sse_max_age_close"),
34+ pendingBytes: Schema.Number,
35+ sessionId: Schema.String,
36+ variant: Schema.Union([
37+ Schema.Literal("streamable-get"),
38+ Schema.Literal("streamable-post"),
39+ Schema.Literal("legacy-sse"),
40+ ]),
41+});
42+const decodeRotationLogEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RotationLogEvent));
43+
44+const flushMicrotasks = async (): Promise<void> => {
45+ await Promise.resolve();
46+ await Promise.resolve();
47+ await Promise.resolve();
48+};
49+
50+const waitFor = async (predicate: () => boolean): Promise<void> => {
51+ for (let attempt = 0; attempt < 20; attempt += 1) {
52+ if (predicate()) return;
53+ await flushMicrotasks();
54+ }
55+ expect(predicate()).toBe(true);
56+};
57+
58+const drainResponse = async (response: Response): Promise<string> => {
59+ const decoder = new TextDecoder();
60+ let body = "";
61+
62+ await Effect.runPromise(
63+ Effect.ignore(
64+ Effect.tryPromise({
65+ try: () =>
66+ response.body?.pipeTo(
67+ new WritableStream<Uint8Array>({
68+ close: () => {
69+ body += decoder.decode();
70+ },
71+ write: (chunk) => {
72+ body += decoder.decode(chunk, { stream: true });
73+ },
74+ }),
75+ ) ?? Promise.resolve(),
76+ catch: () => undefined,
77+ }),
78+ ),
79+ );
80+
81+ return body;
82+};
83+
84+const installStallingTransformStream = () => {
85+ let abortReason: unknown;
86+ let writeCount = 0;
87+ let stalledWriteStarted: (() => void) | undefined;
88+ const stalledWrite = new Promise<void>((resolve) => {
89+ stalledWriteStarted = resolve;
90+ });
91+ const writer = {
92+ abort: (reason: unknown) => {
93+ abortReason = reason;
94+ return Promise.resolve();
95+ },
96+ close: () => Promise.resolve(),
97+ write: () => {
98+ writeCount += 1;
99+ stalledWriteStarted?.();
100+ return new Promise<void>(() => {});
101+ },
102+ };
103+
104+ vi.stubGlobal(
105+ "TransformStream",
106+ class {
107+ readonly readable = new ReadableStream<Uint8Array>();
108+ readonly writable = {
109+ getWriter: () => writer,
110+ };
111+ },
112+ );
113+
114+ return {
115+ abortReason: () => abortReason,
116+ stalledWrite,
117+ writeCount: () => writeCount,
118+ };
119+};
120+
121+const makeExecutionContext = (): ExecutionContext => ({
122+ passThroughOnException: () => {},
123+ props: undefined,
124+ waitUntil: () => {},
125+});
126+
127+const makeWebSocket = (): FakeWebSocket => {
128+ const ws = new EventTarget() as FakeWebSocket;
129+ ws.accepted = false;
130+ ws.closeCode = undefined;
131+ ws.closeReason = undefined;
132+ ws.accept = () => {
133+ ws.accepted = true;
134+ };
135+ ws.close = (code?: number, reason?: string) => {
136+ ws.closeCode = code;
137+ ws.closeReason = reason;
138+ };
139+ return ws;
140+};
141+
142+const makeAgentStub = (ws: FakeWebSocket): FakeAgentStub => ({
143+ setName: async () => {},
144+ getInitializeRequest: async () => ({}),
145+ fetch: async () => ({ webSocket: ws }),
146+});
147+
148+const makeNamespace = (agent: FakeAgentStub) => ({
149+ newUniqueId: () => ({ toString: () => "generated-session" }),
150+ idFromName: (name: string) => ({
151+ equals: () => true,
152+ name,
153+ toString: () => name,
154+ }),
155+ get: () => agent,
156+});
157+
158+const openSse = async () => {
159+ const ws = makeWebSocket();
160+ const agent = makeAgentStub(ws);
161+ const namespace = makeNamespace(agent);
162+ const handler = McpAgent.serve("/mcp", {
163+ binding: "MCP_SESSION",
164+ transport: "streamable-http",
165+ });
166+ const response = await handler.fetch(
167+ new Request("https://executor.sh/mcp", {
168+ headers: {
169+ accept: "text/event-stream",
170+ "mcp-session-id": "session-1",
171+ },
172+ method: "GET",
173+ }),
174+ { MCP_SESSION: namespace },
175+ makeExecutionContext(),
176+ );
177+
178+ expect(response.status).toBe(200);
179+ expect(ws.accepted).toBe(true);
180+ expect(response.body).toBeDefined();
181+
182+ return { response, ws };
183+};
184+
185+const openPostSse = async () => {
186+ const ws = makeWebSocket();
187+ const agent = makeAgentStub(ws);
188+ const namespace = makeNamespace(agent);
189+ const handler = McpAgent.serve("/mcp", {
190+ binding: "MCP_SESSION",
191+ transport: "streamable-http",
192+ });
193+ const response = await handler.fetch(
194+ new Request("https://executor.sh/mcp", {
195+ body: JSON.stringify({
196+ id: 1,
197+ jsonrpc: "2.0",
198+ method: "tools/call",
199+ params: {
200+ arguments: {},
201+ name: "example",
202+ },
203+ }),
204+ headers: {
205+ accept: "application/json, text/event-stream",
206+ "content-type": "application/json",
207+ "mcp-session-id": "session-1",
208+ },
209+ method: "POST",
210+ }),
211+ { MCP_SESSION: namespace },
212+ makeExecutionContext(),
213+ );
214+
215+ expect(response.status).toBe(200);
216+ expect(ws.accepted).toBe(true);
217+ expect(response.body).toBeDefined();
218+
219+ return { response, ws };
220+};
221+
222+const emitAgentEvent = (ws: FakeWebSocket, event: string, close?: true): void => {
223+ ws.dispatchEvent(
224+ new MessageEvent("message", {
225+ data: JSON.stringify({
226+ close,
227+ event,
228+ type: "cf_mcp_agent_event",
229+ }),
230+ }),
231+ );
232+};
233+
234+const emitClose = (ws: FakeWebSocket): void => {
235+ ws.dispatchEvent(new Event("close"));
236+};
237+
238+const rotationLogs = (logs: ReadonlyArray<string>): ReadonlyArray<RotationLog> =>
239+ logs.flatMap((line) => {
240+ const decoded = decodeRotationLogEvent(line);
241+ return Option.isSome(decoded) ? [decoded.value] : [];
242+ });
243+
244+describe("agents SSE max-age rotation", () => {
245+ let errorLogs: string[] = [];
246+ let infoLogs: string[] = [];
247+
248+ beforeEach(() => {
249+ errorLogs = [];
250+ infoLogs = [];
251+ vi.useFakeTimers();
252+ vi.setSystemTime(0);
253+ vi.spyOn(console, "error").mockImplementation((line) => {
254+ errorLogs.push(String(line));
255+ });
256+ vi.spyOn(console, "log").mockImplementation((line) => {
257+ infoLogs.push(String(line));
258+ });
259+ });
260+
261+ afterEach(() => {
262+ vi.unstubAllGlobals();
263+ vi.restoreAllMocks();
264+ vi.useRealTimers();
265+ });
266+
267+ it("closes a healthy draining SSE connection within one keepalive tick after max age", async () => {
268+ const { response, ws } = await openSse();
269+ const drained = drainResponse(response);
270+
271+ await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS);
272+ await waitFor(() => ws.closeCode === 1000);
273+
274+ expect(ws.closeReason).toBe("sse_max_age_rotation");
275+ const [rotationLog] = rotationLogs(infoLogs);
276+ expect(rotationLog?.event).toBe("sse_max_age_close");
277+ expect(rotationLog?.ageMs).toBeGreaterThan(MAX_SSE_AGE_MS);
278+ expect(rotationLog?.ageMs).toBeLessThanOrEqual(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS);
279+ expect(rotationLog?.pendingBytes).toBeGreaterThanOrEqual(0);
280+ expect(errorLogs).toEqual([]);
281+ expect(vi.getTimerCount()).toBe(0);
282+
283+ await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n");
284+ });
285+
286+ it("does not rotate an in-flight POST response past max age", async () => {
287+ const { response, ws } = await openPostSse();
288+ const drained = drainResponse(response);
289+
290+ emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`);
291+ await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS * 4);
292+ await flushMicrotasks();
293+
294+ expect(ws.closeCode).toBeUndefined();
295+ expect(ws.closeReason).toBeUndefined();
296+ expect(rotationLogs(infoLogs)).toEqual([]);
297+
298+ emitAgentEvent(
299+ ws,
300+ `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`,
301+ true,
302+ );
303+ await drained;
304+
305+ expect(ws.closeCode).toBeUndefined();
306+ expect(ws.closeReason).toBeUndefined();
307+ expect(vi.getTimerCount()).toBe(0);
308+ });
309+
310+ it("leaves an SSE connection younger than max age untouched", async () => {
311+ const { response, ws } = await openSse();
312+ const drained = drainResponse(response);
313+
314+ await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS - KEEPALIVE_INTERVAL_MS * 2);
315+ await flushMicrotasks();
316+
317+ expect(ws.closeCode).toBeUndefined();
318+ expect(rotationLogs(infoLogs)).toEqual([]);
319+
320+ emitClose(ws);
321+ await drained;
322+ expect(vi.getTimerCount()).toBe(0);
323+ });
324+
325+ it("still closes a stalled SSE writer at the byte cap without logging rotation", async () => {
326+ const stalledFrame = `event: message\ndata: ${"x".repeat(2 * 1024 * 1024)}\n\n`;
327+ const transform = installStallingTransformStream();
328+ const { ws } = await openSse();
329+
330+ emitAgentEvent(ws, stalledFrame);
331+ await transform.stalledWrite;
332+ expect(transform.writeCount()).toBe(1);
333+
334+ const stalledFrameBytes = encoder.encode(stalledFrame).byteLength;
335+ expect(stalledFrameBytes).toBeLessThan(MAX_PENDING_SSE_BYTES);
336+
337+ for (let attempt = 0; attempt < 8 && ws.closeCode === undefined; attempt += 1) {
338+ emitAgentEvent(ws, stalledFrame);
339+ }
340+
341+ expect(ws.closeCode).toBe(1013);
342+ expect(ws.closeReason).toBe("SSE client not draining");
343+ expect(transform.abortReason()).toBeInstanceOf(Error);
344+ expect(rotationLogs(infoLogs)).toEqual([]);
345+ expect(vi.getTimerCount()).toBe(0);
346+ });
347+
348+ it("cleans up timers when the client closes before max age", async () => {
349+ const { response, ws } = await openSse();
350+ const drained = drainResponse(response);
351+
352+ await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS);
353+ emitClose(ws);
354+ await drained;
355+
356+ expect(ws.closeCode).toBeUndefined();
357+ expect(rotationLogs(infoLogs)).toEqual([]);
358+ expect(vi.getTimerCount()).toBe(0);
359+ });
360+});
packages/hosts/cloudflare/src/test-setup.ts+9−0
@@ -0,0 +1,9 @@
1+import { URLPattern } from "urlpattern-polyfill";
2+
3+type UrlPatternPolyfillGlobal = Omit<typeof globalThis, "URLPattern"> & {
4+ URLPattern?: typeof URLPattern;
5+};
6+
7+const globals = globalThis as UrlPatternPolyfillGlobal;
8+
9+globals.URLPattern ??= URLPattern;
packages/hosts/cloudflare/src/test-stubs/cloudflare-email.ts+7−0
@@ -0,0 +1,7 @@
1+export class EmailMessage {
2+ constructor(
3+ readonly from: string,
4+ readonly to: string,
5+ readonly raw: ReadableStream | string,
6+ ) {}
7+}
packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts+9−0
@@ -0,0 +1,9 @@
1+export const env: Record<string, unknown> = {};
2+
3+export class DurableObject {}
4+
5+export class RpcTarget {}
6+
7+export class WorkerEntrypoint {}
8+
9+export const exports: Record<string, unknown> = {};
packages/hosts/cloudflare/vitest.config.ts+16−0
@@ -1,8 +1,24 @@
1+import { dirname, resolve } from "node:path";
2+import { fileURLToPath } from "node:url";
13 import { defineConfig } from "vitest/config";
24
5+const __dirname = dirname(fileURLToPath(import.meta.url));
6+
37 export default defineConfig({
8+ resolve: {
9+ alias: {
10+ "cloudflare:email": resolve(__dirname, "./src/test-stubs/cloudflare-email.ts"),
11+ "cloudflare:workers": resolve(__dirname, "./src/test-stubs/cloudflare-workers.ts"),
12+ },
13+ },
414 test: {
515 include: ["src/**/*.test.ts"],
616 passWithNoTests: true,
17+ setupFiles: ["./src/test-setup.ts"],
18+ server: {
19+ deps: {
20+ inline: ["agents", "partyserver"],
21+ },
22+ },
723 },
824 });
packages/kernel/core/README.md+1−1
@@ -37,7 +37,7 @@ The runtime is passed a `SandboxToolInvoker` that bridges sandbox-side tool call · 36 unmodified lines
3737
3838 ## Status
3939
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).
4141
4242 ## License
4343
packages/kernel/core/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/codemode-core",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/kernel/core"
1313 },
1414 "files": [
packages/kernel/runtime-quickjs/README.md+1−1
@@ -52,7 +52,7 @@ Use this when you want a different WASM variant (e.g. debug builds, QuickJS-NG) · 51 unmodified lines
5252
5353 ## Status
5454
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).
5656
5757 ## License
5858
packages/kernel/runtime-quickjs/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/runtime-quickjs",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/kernel/runtime-quickjs"
1313 },
1414 "files": [
packages/plugins/desktop-settings/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-desktop-settings",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/desktop-settings"
1313 },
1414 "files": [
packages/plugins/example/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-example",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/example"
1313 },
1414 "files": [
packages/plugins/file-secrets/README.md+1−1
@@ -52,7 +52,7 @@ Secrets are stored unencrypted in a plain JSON file. Use [`@executor-js/plugin-k · 51 unmodified lines
5252
5353 ## Status
5454
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).
5656
5757 ## License
5858
packages/plugins/file-secrets/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-file-secrets",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/file-secrets"
1313 },
1414 "files": [
packages/plugins/google/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-google",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/google"
1313 },
1414 "files": [
packages/plugins/graphql/README.md+1−1
@@ -75,7 +75,7 @@ import { graphqlPlugin } from "@executor-js/plugin-graphql/core"; · 74 unmodified lines
7575
7676 ## Status
7777
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).
7979
8080 ## License
8181
packages/plugins/graphql/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-graphql",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/graphql"
1313 },
1414 "files": [
packages/plugins/keychain/README.md+1−1
@@ -52,7 +52,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain/core"; · 51 unmodified lines
5252
5353 ## Status
5454
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).
5656
5757 ## License
5858
packages/plugins/keychain/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-keychain",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/keychain"
1313 },
1414 "files": [
packages/plugins/mcp/README.md+1−1
@@ -60,7 +60,7 @@ import { mcpPlugin } from "@executor-js/plugin-mcp/core"; · 59 unmodified lines
6060
6161 ## Status
6262
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).
6464
6565 ## License
6666
packages/plugins/mcp/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-mcp",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/mcp"
1313 },
1414 "files": [
packages/plugins/microsoft/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-microsoft",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/microsoft"
1313 },
1414 "files": [
packages/plugins/onepassword/README.md+1−1
@@ -72,7 +72,7 @@ import { onepasswordPlugin } from "@executor-js/plugin-onepassword/core"; · 71 unmodified lines
7272
7373 ## Status
7474
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).
7676
7777 ## License
7878
packages/plugins/onepassword/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-onepassword",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/onepassword"
1313 },
1414 "files": [
packages/plugins/openapi/README.md+1−1
@@ -76,7 +76,7 @@ import { openApiPlugin } from "@executor-js/plugin-openapi/core"; · 75 unmodified lines
7676
7777 ## Status
7878
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).
8080
8181 ## License
8282
packages/plugins/openapi/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-openapi",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/openapi"
1313 },
1414 "files": [
packages/plugins/toolkits/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-toolkits",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/toolkits"
1313 },
1414 "files": [
packages/plugins/workos-vault/package.json+3−3
@@ -1,14 +1,14 @@
11 {
22 "name": "@executor-js/plugin-workos-vault",
33 "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",
55 "bugs": {
6− "url": "https://github.com/RhysSullivan/executor/issues"
6+ "url": "https://github.com/UsefulSoftwareCo/executor/issues"
77 },
88 "license": "MIT",
99 "repository": {
1010 "type": "git",
11− "url": "git+https://github.com/RhysSullivan/executor.git",
11+ "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
1212 "directory": "packages/plugins/workos-vault"
1313 },
1414 "files": [
packages/react/src/api/atoms.tsx+4−1
@@ -500,6 +500,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 499 unmodified lines
500500 readonly grant: OAuthGrant;
501501 readonly clientId: string;
502502 readonly resource?: string | null;
503+ readonly originIntegration?: IntegrationSlug | null;
503504 };
504505 },
505506 ) =>
@@ -512,7 +513,9 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 6 unmodified lines
512513 tokenUrl: arg.payload.tokenUrl,
513514 resource: arg.payload.resource ?? null,
514515 clientId: arg.payload.clientId,
515− origin: { kind: "manual" },
516+ // Mirror the server's stamp so the just-registered app matches its
517+ // integration in the picker immediately (before the refetch lands).
518+ origin: { kind: "manual", integration: arg.payload.originIntegration ?? null },
516519 };
517520 const index = rows.findIndex(
518521 (client) => client.owner === summary.owner && client.slug === summary.slug,
packages/react/src/components/add-account-modal.test.ts+38−7
@@ -31,6 +31,7 @@ const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): Aut · 30 unmodified lines
3131 });
3232
3333 type ProbeResult = {
34+ readonly issuer?: string | null;
3435 readonly authorizationUrl: string;
3536 readonly tokenUrl: string;
3637 readonly resource?: string | null;
@@ -42,6 +43,7 @@ type ProbeResult = { · 5 unmodified lines
4243 type RegisterArgs = {
4344 readonly owner: Owner;
4445 readonly slug: OAuthClientSlug;
46+ readonly issuer?: string | null;
4547 readonly registrationEndpoint: string;
4648 readonly authorizationUrl: string;
4749 readonly tokenUrl: string;
@@ -306,6 +308,7 @@ describe("runDcrConnect", () => { · 258 unmodified lines
306308 const probe = (_url: string): Promise<ProbeResult | null> => {
307309 calls.push("probe");
308310 return Promise.resolve({
311+ issuer: "https://auth.example.com",
309312 authorizationUrl: "https://auth.example.com/authorize",
310313 tokenUrl: "https://auth.example.com/token",
311314 resource: "https://mcp.example.com/mcp",
@@ -330,7 +333,6 @@ describe("runDcrConnect", () => { · 18 unmodified lines
330333 discoveryUrl: "https://mcp.example.com/mcp",
331334 owner: "user",
332335 integrationName: "Linear MCP",
333− existingSlugs: [],
334336 redirectUri: "https://localhost:5394/api/oauth/callback",
335337 integration: TEST_INTEGRATION,
336338 },
@@ -342,6 +344,8 @@ describe("runDcrConnect", () => { · 5 unmodified lines
342344 // DCR always mints an authorization-code/PKCE client; callers do not choose
343345 // a grant here.
344346 expect(registerArgs).not.toBeNull();
347+ expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com");
348+ expect(registerArgs!.issuer).toBe("https://auth.example.com");
345349 expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register");
346350 expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize");
347351 expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token");
@@ -379,7 +383,6 @@ describe("runDcrConnect", () => { · 31 unmodified lines
379383 resourceFallback: "https://mcp.example.com/mcp",
380384 owner: "user",
381385 integrationName: "App",
382− existingSlugs: [],
383386 declaredScopes: ["declared.scope"],
384387 integration: TEST_INTEGRATION,
385388 },
@@ -416,7 +419,6 @@ describe("runDcrConnect", () => { · 30 unmodified lines
416419 discoveryUrl: "https://auth.example.com/token",
417420 owner: "user",
418421 integrationName: "App",
419− existingSlugs: [],
420422 integration: TEST_INTEGRATION,
421423 },
422424 );
@@ -448,7 +450,6 @@ describe("runDcrConnect", () => { · 25 unmodified lines
448450 discoveryUrl: "https://mcp.example.com/mcp",
449451 owner: "user",
450452 integrationName: "App",
451− existingSlugs: [],
452453 integration: TEST_INTEGRATION,
453454 },
454455 );
@@ -483,7 +484,6 @@ describe("runDcrConnect", () => { · 28 unmodified lines
483484 discoveryUrl: "https://mcp.example.com/mcp",
484485 owner: "user",
485486 integrationName: "App",
486− existingSlugs: [],
487487 integration: TEST_INTEGRATION,
488488 },
489489 );
@@ -513,7 +513,6 @@ describe("runDcrConnect", () => { · 23 unmodified lines
513513 discoveryUrl: "https://mcp.example.com/mcp",
514514 owner: "user",
515515 integrationName: "App",
516− existingSlugs: [],
517516 integration: TEST_INTEGRATION,
518517 },
519518 );
@@ -552,7 +551,6 @@ describe("runDcrConnect", () => { · 32 unmodified lines
552551 discoveryUrl: "https://mcp.example.com/mcp",
553552 owner: "user",
554553 integrationName: "App",
555− existingSlugs: [],
556554 integration: TEST_INTEGRATION,
557555 },
558556 );
@@ -570,4 +568,37 @@ describe("runDcrConnect", () => { · 11 unmodified lines
570568 });
571569 expect(started).toBe(false);
572570 });
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+ });
573604 });
packages/react/src/components/add-account-modal.tsx+159−14
@@ -62,6 +62,8 @@ import { · 61 unmodified lines
6262 import {
6363 clientDisplayName,
6464 clientHost,
65+ optimisticDcrClientSlug,
66+ selectDcrClientsForIntegration,
6567 uniqueClientSlug,
6668 useOAuthClientsForIntegration,
6769 type OAuthClientOption,
@@ -79,13 +81,14 @@ import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; · 11 unmodified lines
7981 import { connectionIdentifier } from "../lib/connection-name";
8082 import { Badge } from "./badge";
8183 import { Button } from "./button";
84+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
8285 import {
8386 DropdownMenu,
8487 DropdownMenuContent,
8588 DropdownMenuItem,
8689 DropdownMenuTrigger,
8790 } from "./dropdown-menu";
88−import { EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react";
91+import { ChevronDown, EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react";
8992 import {
9093 Dialog,
9194 DialogContent,
@@ -668,6 +671,7 @@ export async function runCimdConnect( · 576 unmodified lines
668671
669672 /** Discovery result from the probe step (subset of the `probeOAuth` response). */
670673 type DcrProbeResult = {
674+ readonly issuer?: string | null;
671675 readonly authorizationUrl: string;
672676 readonly tokenUrl: string;
673677 readonly resource?: string | null;
@@ -679,6 +683,7 @@ type DcrProbeResult = { · 5 unmodified lines
679683 type DcrRegisterArgs = {
680684 readonly owner: Owner;
681685 readonly slug: OAuthClientSlug;
686+ readonly issuer?: string | null;
682687 readonly registrationEndpoint: string;
683688 readonly authorizationUrl: string;
684689 readonly tokenUrl: string;
@@ -739,8 +744,6 @@ type RunDcrConnectInput = { · 54 unmodified lines
739744 readonly resourceFallback?: string;
740745 readonly owner: Owner;
741746 readonly integrationName: string;
742− /** The owner's existing client slugs, so the minted slug stays unique. */
743− readonly existingSlugs: readonly string[];
744747 /** Scopes declared by the integration's method (override the probed ones). */
745748 readonly declaredScopes?: readonly string[];
746749 /** Browser-facing callback URL registered with DCR when available. */
@@ -773,11 +776,12 @@ export async function runDcrConnect( · 26 unmodified lines
773776 const registrationEndpoint = probe.registrationEndpoint;
774777 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe };
775778
776− const slug = uniqueClientSlug(input.integrationName, input.existingSlugs);
779+ const slug = optimisticDcrClientSlug(probe.issuer ?? registrationEndpoint);
777780 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);
778781 const minted = await deps.register({
779782 owner: input.owner,
780783 slug,
784+ issuer: probe.issuer ?? null,
781785 registrationEndpoint,
782786 authorizationUrl: probe.authorizationUrl,
783787 tokenUrl: probe.tokenUrl,
@@ -1160,6 +1164,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 376 unmodified lines
11601164 // FIX 3 escape hatch: when no registered app matched the integration's
11611165 // endpoints, the unmatched apps are collapsed behind an opt-in expander.
11621166 const [showOtherApps, setShowOtherApps] = useState(false);
1167+ // Auto-registered (DCR) clients are plumbing, not pickable apps: they live in a
1168+ // collapsed management section at the bottom of the OAuth tab, revealed on
1169+ // demand so they can be inspected and deleted without cluttering the picker.
1170+ const [showAutoRegistered, setShowAutoRegistered] = useState(false);
11631171 // Inline OAuth app management — edit re-opens the registration form for an
11641172 // existing app (upsert by owner+slug); remove confirms before deleting. Both
11651173 // hold the FULL app summary (with endpoints + resource) so the edit prefill
@@ -1403,6 +1411,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 237 unmodified lines
14031411 // unconditionally; in DCR mode the result is ignored until/unless we fall back.
14041412 const {
14051413 clients: oauthApps,
1414+ nearMatches: oauthNearApps,
14061415 otherClients: oauthOtherApps,
14071416 loading: oauthLoading,
14081417 endpointMatched: oauthEndpointMatched,
@@ -1415,8 +1424,29 @@ function AddAccountModalView(props: AddAccountModalProps) { · 6 unmodified lines
14151424 // unrelated provider's app.
14161425 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
14171426 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1427+ // Recorded intent: a manual app registered from THIS integration's dialog is
1428+ // a tier-1 match regardless of host.
1429+ integration,
14181430 requireEndpointMatch: isDcr,
14191431 });
1432+ // Auto-registered (DCR) clients for THIS integration. Excluded from the picker
1433+ // above, but surfaced in a collapsed management section so they stay
1434+ // deletable. Reads the same optimistic list the picker does.
1435+ const dcrClients = useMemo(
1436+ () =>
1437+ selectDcrClientsForIntegration(clientSummaries as readonly OAuthClientOption[], {
1438+ integration,
1439+ tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
1440+ authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1441+ }),
1442+ [
1443+ clientSummaries,
1444+ integration,
1445+ method?.oauth?.tokenUrl,
1446+ method?.oauth?.authorizationUrl,
1447+ oauthFallbackProbe,
1448+ ],
1449+ );
14201450 const oauthPopup = useOAuthPopupFlow({
14211451 popupName: "add-account-oauth",
14221452 detectPopupClosed: false,
@@ -1433,8 +1463,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines
14331463 // Editing reuses the registration form (createClient upserts by owner+slug),
14341464 // so it occupies the same full-bleed sub-view as registering.
14351465 const oauthEditing = isOAuth && editingClient !== null;
1466+ // Resolve the picked slug across every PICKABLE tier: exact matches, the
1467+ // subdued near-match tier, and the unrelated escape-hatch apps. All three are
1468+ // selectable (only the default auto-selection is restricted to tier 1), so the
1469+ // connect button must resolve a client from any of them. DCR clients are not
1470+ // in these lists, so they can never be chosen as the connect app.
14361471 const chosenClient: OAuthClientOption | null =
1437− oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null;
1472+ [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find(
1473+ (c: OAuthClientOption) => String(c.slug) === selectedApp,
1474+ ) ?? null;
14381475 const oauthBusy = ccBusy || oauthPopup.busy;
14391476 const cimdConnecting = cimdBusy || oauthPopup.busy;
14401477 const dcrConnecting = dcrBusy || oauthPopup.busy;
@@ -1898,6 +1935,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 457 unmodified lines
18981935 payload: {
18991936 owner: args.owner,
19001937 slug: args.slug,
1938+ issuer: args.issuer ?? null,
19011939 registrationEndpoint: args.registrationEndpoint,
19021940 authorizationUrl: args.authorizationUrl,
19031941 tokenUrl: args.tokenUrl,
@@ -1945,9 +1983,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 41 unmodified lines
19451983 resourceFallback: method.oauth?.discoveryUrl,
19461984 owner: dcrOwner,
19471985 integrationName,
1948− existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
1949− String(app.slug),
1950− ),
1986+ // DCR slugs are server-keyed (Part A): the connect path no longer depends
1987+ // on the picker's app list, so it need not be threaded here.
19511988 declaredScopes: method.oauth?.scopes,
19521989 redirectUri: oauthCallbackUrl(),
19531990 integration,
@@ -2014,11 +2051,18 @@ function AddAccountModalView(props: AddAccountModalProps) { · 60 unmodified lines
20142051 <div className="px-5 py-5">
20152052 <OAuthClientForm
20162053 integrationName={integrationName}
2017− existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
2018− String(app.slug),
2019− )}
2054+ existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}
20202055 fixedSlug={editingClient.slug}
20212056 fixedOwner={editingClient.owner}
2057+ // Preserve the app's already-recorded origin verbatim (only a
2058+ // "manual" origin carries a recorded integration; DCR apps
2059+ // aren't edited through this form). Editing must never
2060+ // re-derive this from the current dialog's integration.
2061+ intentIntegration={
2062+ editingClient.origin.kind === "manual"
2063+ ? (editingClient.origin.integration ?? null)
2064+ : null
2065+ }
20222066 prefill={{
20232067 authorizationUrl: editingClient.authorizationUrl,
20242068 tokenUrl: editingClient.tokenUrl,
@@ -2043,9 +2087,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 18 unmodified lines
20432087 <div className="px-5 py-5">
20442088 <OAuthClientForm
20452089 integrationName={integrationName}
2046− existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>
2047− String(app.slug),
2048− )}
2090+ integrationSlug={integration}
2091+ existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}
20492092 autoRegisterRejectedReason={dcrFallbackMessage}
20502093 prefill={{
20512094 authorizationUrl:
@@ -2063,6 +2106,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 11 unmodified lines
20632106 null,
20642107 scopes: method.oauth?.scopes,
20652108 discoveredScopes: oauthFallbackProbe?.scopesSupported,
2109+ issuer: oauthFallbackProbe?.issuer ?? null,
20662110 registrationEndpoint:
20672111 method.oauth?.registrationEndpoint ??
20682112 oauthFallbackProbe?.registrationEndpoint ??
@@ -2324,6 +2368,107 @@ function AddAccountModalView(props: AddAccountModalProps) { · 255 unmodified lines
23242368 </Button>
23252369 </RadioGroup>
23262370 )}
2371+
2372+ {/* Tier 2 — apps that only match by root domain (a
2373+ near-miss, e.g. the same provider's MCP app for a REST
2374+ integration). Visually separated and subdued so they are
2375+ never mistaken for the integration's own app, but still
2376+ selectable as an escape hatch. Shown alongside tier 1, not
2377+ mixed into it. */}
2378+ {oauthNearApps.length > 0 && (
2379+ <div className="space-y-2 border-t border-border/60 pt-3">
2380+ <p className="text-xs font-medium text-muted-foreground">
2381+ Other apps on this provider
2382+ </p>
2383+ <p className="text-xs text-muted-foreground">
2384+ These share {integrationName}&apos;s domain but weren&apos;t
2385+ registered for it. Pick one only if you know it applies.
2386+ </p>
2387+ <RadioGroup
2388+ value={selectedApp}
2389+ onValueChange={setPickedApp}
2390+ className="gap-2 pt-1"
2391+ >
2392+ {oauthNearApps.map((app: OAuthClientOption) => (
2393+ <OAuthAppRadioRow
2394+ key={String(app.slug)}
2395+ app={app}
2396+ idPrefix="near-app"
2397+ variant="other"
2398+ showOwnerLabel={ownerDisplay.showOwnerLabels}
2399+ onManage={manageHandlersFor(app)}
2400+ />
2401+ ))}
2402+ </RadioGroup>
2403+ </div>
2404+ )}
2405+
2406+ {/* Auto-registered (DCR) clients: plumbing, never
2407+ pickable. Kept here, collapsed, so they can be reviewed
2408+ and deleted without cluttering the picker. */}
2409+ {dcrClients.length > 0 && (
2410+ <div className="border-t border-border/60 pt-3">
2411+ <Collapsible
2412+ open={showAutoRegistered}
2413+ onOpenChange={setShowAutoRegistered}
2414+ >
2415+ <CollapsibleTrigger className="flex w-full items-center justify-between text-xs font-medium text-muted-foreground transition-colors hover:text-foreground">
2416+ <span>Auto-registered clients ({dcrClients.length})</span>
2417+ <ChevronDown
2418+ className={cn(
2419+ "size-3.5 transition-transform",
2420+ showAutoRegistered && "rotate-180",
2421+ )}
2422+ aria-hidden="true"
2423+ />
2424+ </CollapsibleTrigger>
2425+ <CollapsibleContent>
2426+ <div className="space-y-2 pt-2">
2427+ <p className="text-xs text-muted-foreground">
2428+ Created automatically when connecting. Reused across
2429+ connections, not selectable as your app.
2430+ </p>
2431+ {dcrClients.map((app: OAuthClientOption) => (
2432+ <div
2433+ key={String(app.slug)}
2434+ className="flex items-center gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
2435+ >
2436+ <span className="min-w-0 flex-1">
2437+ <span className="block text-sm font-medium">
2438+ {clientDisplayName(String(app.slug))}
2439+ </span>
2440+ <span className="block truncate text-xs text-muted-foreground">
2441+ {clientHost(app.tokenUrl)}
2442+ </span>
2443+ </span>
2444+ {ownerDisplay.showOwnerLabels ? (
2445+ <Badge variant="outline">
2446+ {ownerLabel(app.owner)}
2447+ </Badge>
2448+ ) : null}
2449+ <Button
2450+ type="button"
2451+ variant="ghost"
2452+ size="sm"
2453+ className="shrink-0 text-destructive hover:text-destructive"
2454+ onClick={() => {
2455+ const summary = clientSummaries.find(
2456+ (c: OAuthClientSummary) =>
2457+ c.owner === app.owner &&
2458+ String(c.slug) === String(app.slug),
2459+ );
2460+ if (summary) setRemovingClient(summary);
2461+ }}
2462+ >
2463+ Remove
2464+ </Button>
2465+ </div>
2466+ ))}
2467+ </div>
2468+ </CollapsibleContent>
2469+ </Collapsible>
2470+ </div>
2471+ )}
23272472 </div>
23282473 )
23292474 ) : (
packages/react/src/components/oauth-client-form.test.ts+29−1
@@ -1,6 +1,11 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { IntegrationSlug } from "@executor-js/sdk/shared";
23
3−import { canSubmitOAuthClientForm, registrationScopes } from "./oauth-client-form";
4+import {
5+ canSubmitOAuthClientForm,
6+ registrationScopes,
7+ resolveOriginIntegration,
8+} from "./oauth-client-form";
49
510 const validBase = {
611 submitting: false,
@@ -36,6 +41,29 @@ describe("registrationScopes", () => { · 29 unmodified lines
3641 });
3742 });
3843
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+
3967 describe("canSubmitOAuthClientForm", () => {
4068 it("allows authorization-code public clients with no client secret", () => {
4169 expect(
packages/react/src/components/oauth-client-form.tsx+46−4
@@ -1,7 +1,12 @@
11 import { useMemo, useState } from "react";
22 import { useAtomSet } from "@effect/atom-react";
33 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";
510 import { toast } from "sonner";
611
712 import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";
@@ -9,7 +14,7 @@ import { ownerLabelForHost } from "../api/owner-display"; · 1 unmodified line
914 import { trackEvent } from "../api/analytics";
1015 import { useOrganizationId } from "../api/organization-context";
1116 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";
1318 import { oauthCallbackUrl } from "../plugins/oauth-sign-in";
1419 import {
1520 ConnectionOwnerDropdown,
@@ -36,6 +41,7 @@ import { RadioGroup, RadioGroupItem } from "./radio-group"; · 20 unmodified lines
3641 // ---------------------------------------------------------------------------
3742
3843 export interface OAuthClientFormPrefill {
44+ readonly issuer?: string | null;
3945 readonly authorizationUrl?: string;
4046 readonly tokenUrl?: string;
4147 readonly resource?: string | null;
@@ -70,6 +76,19 @@ export const registrationScopes = ( · 28 unmodified lines
7076 discoveredScopes: readonly string[],
7177 ): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes);
7278
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+
7392 export const canSubmitOAuthClientForm = (input: {
7493 readonly submitting: boolean;
7594 readonly name: string;
@@ -89,6 +108,19 @@ export const canSubmitOAuthClientForm = (input: { · 13 unmodified lines
89108 export function OAuthClientForm(props: {
90109 /** Human label for the integration this app backs (used in toasts + default name). */
91110 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;
92124 /** Existing client slugs, so the generated slug stays unique across apps. */
93125 readonly existingSlugs: readonly string[];
94126 /** Endpoints/scopes declared by the integration's OAuth method. */
@@ -112,6 +144,8 @@ export function OAuthClientForm(props: { · 17 unmodified lines
112144 }) {
113145 const {
114146 integrationName,
147+ integrationSlug,
148+ intentIntegration,
115149 existingSlugs,
116150 prefill,
117151 fixedSlug,
@@ -147,6 +181,7 @@ export function OAuthClientForm(props: { · 29 unmodified lines
147181 const [clientId, setClientId] = useState(prefill?.clientId ?? "");
148182 const [clientSecret, setClientSecret] = useState("");
149183 const [issuerUrl, setIssuerUrl] = useState("");
184+ const [discoveredIssuer, setDiscoveredIssuer] = useState<string | null>(prefill?.issuer ?? null);
150185 const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");
151186 const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");
152187 const [resource, setResource] = useState(prefill?.resource ?? null);
@@ -226,6 +261,7 @@ export function OAuthClientForm(props: { · 73 unmodified lines
226261 return;
227262 }
228263 const result = exit.value;
264+ setDiscoveredIssuer(result.issuer ?? null);
229265 setAuthorizationUrl(result.authorizationUrl);
230266 setTokenUrl(result.tokenUrl);
231267 setResource(result.resource ?? null);
@@ -247,11 +283,13 @@ export function OAuthClientForm(props: { · 15 unmodified lines
247283 const handleRegisterDynamic = async () => {
248284 if (!canRegisterDynamic || registering) return;
249285 setRegistering(true);
250− const slug = fixedSlug ?? uniqueClientSlug(name, existingSlugs);
286+ const slug =
287+ fixedSlug ?? optimisticDcrClientSlug(discoveredIssuer ?? registrationEndpoint.trim());
251288 const exit = await doRegisterDynamic({
252289 payload: {
253290 owner,
254291 slug,
292+ issuer: discoveredIssuer,
255293 registrationEndpoint: registrationEndpoint.trim(),
256294 authorizationUrl: authorizationUrl.trim(),
257295 tokenUrl: tokenUrl.trim(),
@@ -273,7 +311,7 @@ export function OAuthClientForm(props: { · 15 unmodified lines
273311 }
274312 trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true });
275313 toast.success(`Registered ${integrationName} OAuth app`);
276− onCreated({ owner, slug });
314+ onCreated({ owner, slug: exit.value.client });
277315 };
278316
279317 const handleSubmit = async () => {
@@ -290,6 +328,10 @@ export function OAuthClientForm(props: { · 10 unmodified lines
290328 clientId: clientId.trim(),
291329 clientSecret: clientSecret.trim(),
292330 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),
293335 },
294336 reactivityKeys: oauthClientWriteKeys,
295337 });
packages/react/src/plugins/dcr-root-domain-isolation.test.ts+125−0
@@ -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+});
packages/react/src/plugins/use-effective-oauth-client.test.ts+193−1
@@ -1,8 +1,11 @@
11 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";
34
45 import {
6+ optimisticDcrClientSlug,
57 selectClientsForEndpoints,
8+ selectDcrClientsForIntegration,
69 uniqueClientSlug,
710 type OAuthClientOption,
811 } from "./use-effective-oauth-client";
@@ -13,6 +16,7 @@ const app = ( · 4 unmodified lines
1316 readonly owner?: Owner;
1417 readonly authorizationUrl: string;
1518 readonly tokenUrl: string;
19+ readonly origin?: OAuthClientOrigin;
1620 },
1721 ): OAuthClientOption => ({
1822 owner: opts.owner ?? "user",
@@ -21,6 +25,7 @@ const app = ( · 2 unmodified lines
2125 authorizationUrl: opts.authorizationUrl,
2226 tokenUrl: opts.tokenUrl,
2327 clientId: "client-id",
28+ origin: opts.origin ?? { kind: "manual" },
2429 });
2530
2631 const google = app("google-app", {
@@ -89,6 +94,27 @@ describe("selectClientsForEndpoints", () => { · 62 unmodified lines
8994 ]);
9095 });
9196
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+
92118 it("treats every app as usable when no endpoint is declared", () => {
93119 const result = selectClientsForEndpoints([google, spotify], {});
94120 expect(result.endpointMatched).toBe(true);
@@ -133,6 +159,161 @@ describe("selectClientsForEndpoints", () => { · 38 unmodified lines
133159 });
134160 expect(result.matched.map((a: OAuthClientOption) => a.owner)).toEqual(["user", "org"]);
135161 });
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+ });
136317 });
137318
138319 describe("uniqueClientSlug", () => {
@@ -144,3 +325,14 @@ describe("uniqueClientSlug", () => { · 5 unmodified lines
144325 );
145326 });
146327 });
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+});
packages/react/src/plugins/use-effective-oauth-client.tsx+230−52
@@ -1,6 +1,12 @@
1+import { useMemo } from "react";
12 import { useAtomValue } from "@effect/atom-react";
23 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";
410 import { getDomain } from "tldts";
511
612 import { oauthClientsOptimisticAtom } from "../api/atoms";
@@ -9,12 +15,17 @@ import { oauthClientsOptimisticAtom } from "../api/atoms"; · 2 unmodified lines
915 // OAuth client (registered app) selection for an integration's connect flow.
1016 //
1117 // 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`.
1829 // ---------------------------------------------------------------------------
1930
2031 export interface OAuthClientOption {
@@ -24,8 +35,18 @@ export interface OAuthClientOption { · 3 unmodified lines
2435 readonly authorizationUrl: string;
2536 readonly tokenUrl: string;
2637 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;
2742 }
2843
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+
2950 const hostOf = (url: string): string | undefined => {
3051 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
3152 try {
@@ -46,35 +67,54 @@ const getRootDomain = (url: string): string | undefined => { · 14 unmodified lines
4667 };
4768
4869 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. */
5274 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. */
5684 readonly otherClients: readonly OAuthClientOption[];
5785 /** True until the clients list has loaded at least once. */
5886 readonly loading: boolean;
5987 /**
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.
6289 *
6390 * - `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`.
7097 */
7198 readonly endpointMatched: boolean;
7299 /** 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`
74101 * once loaded. */
75102 readonly displayRegisterCTA: boolean;
76103 }
77104
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+
78118 /** Sort apps user-owned first (so the user's own apps surface before shared
79119 * workspace apps). */
80120 const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>
@@ -83,23 +123,37 @@ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClient · 2 unmodified lines
83123 );
84124
85125 /**
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.
88132 *
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.
95145 *
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).
97148 */
98149 export function selectClientsForEndpoints(
99150 all: readonly OAuthClientOption[],
100151 endpoints: {
101152 readonly tokenUrl?: string;
102153 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;
103157 /** When set, an integration that targets a SPECIFIC server (MCP, whose
104158 * endpoints are discovered at connect) must match by endpoint — absent
105159 * endpoints mean NO match (show the register CTA), never "every app
@@ -108,36 +162,81 @@ export function selectClientsForEndpoints( · 2 unmodified lines
108162 },
109163 ): {
110164 readonly matched: readonly OAuthClientOption[];
165+ readonly nearMatches: readonly OAuthClientOption[];
111166 readonly unmatched: readonly OAuthClientOption[];
112167 readonly endpointMatched: boolean;
113168 } {
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;
114183 const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined;
115184 const wantedAuthorizationRoot = endpoints.authorizationUrl
116185 ? getRootDomain(endpoints.authorizationUrl)
117186 : undefined;
187+
118188 // No declared endpoints. A server-targeting integration must NOT match every
119189 // 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.
121191 if (!wantedTokenRoot && !wantedAuthorizationRoot) {
122192 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+ };
124203 }
125− return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true };
204+ return {
205+ matched: sortUserFirst(manual),
206+ nearMatches: [],
207+ unmatched: [],
208+ endpointMatched: true,
209+ };
126210 }
211+
127212 const matched: OAuthClientOption[] = [];
213+ const nearMatches: OAuthClientOption[] = [];
128214 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);
130218 const appTokenRoot = getRootDomain(app.tokenUrl);
131219 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);
137235 else unmatched.push(app);
138236 }
139237 return {
140238 matched: sortUserFirst(matched),
239+ nearMatches: sortUserFirst(nearMatches),
141240 unmatched: sortUserFirst(unmatched),
142241 endpointMatched: matched.length > 0,
143242 };
@@ -146,6 +245,7 @@ export function selectClientsForEndpoints( · 2 unmodified lines
146245 export function useOAuthClientsForIntegration(opts: {
147246 readonly tokenUrl?: string;
148247 readonly authorizationUrl?: string;
248+ readonly integration?: IntegrationSlug;
149249 readonly requireEndpointMatch?: boolean;
150250 }): UseOAuthClientsResult {
151251 // Read the optimistic list so a just-registered/edited/removed app paints
@@ -153,9 +253,29 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line
153253 // lands. The modal's management menu reads the same optimistic atom, so the
154254 // picker rows and their actions stay consistent.
155255 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) {
157276 return {
158277 clients: [],
278+ nearMatches: [],
159279 otherClients: [],
160280 loading: true,
161281 endpointMatched: true,
@@ -163,19 +283,16 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line
163283 };
164284 }
165285
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.
177293 return {
178294 clients: endpointMatched ? matched : [],
295+ nearMatches,
179296 otherClients: endpointMatched ? [] : unmatched,
180297 loading: false,
181298 endpointMatched,
@@ -183,6 +300,49 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line
183300 };
184301 }
185302
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+
186346 const slugifyName = (name: string): string =>
187347 name
188348 .toLowerCase()
@@ -199,6 +359,24 @@ export function uniqueClientSlug(name: string, existing: readonly string[]): OAu · 10 unmodified lines
199359 return OAuthClientSlug.make(`${base}-${suffix}`);
200360 }
201361
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+
202380 /** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */
203381 export function clientDisplayName(slug: string): string {
204382 const text = slug.replace(/[-_]/g, " ").trim();
patches/agents@0.17.3.patch+138−37
@@ -1,8 +1,33 @@
1+diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts
2+index c8fad448e8797b89690a99d93490d1363851b225..439da01e789713d217778e8bdebfa1a9d52a71e2 100644
3+--- a/dist/mcp/index.d.ts
4++++ b/dist/mcp/index.d.ts
5+@@ -29,6 +29,7 @@ import {
6+ xt as MCPClientOAuthCallbackConfig,
7+ zt as ElicitResult
8+ } from "../agent-tool-types-CNyE1iz_.js";
9++declare const MAX_SSE_AGE_MS = 300000;
10+ export {
11+ type ClearableEventStore,
12+ type CreateMcpHandlerOptions,
13+@@ -41,6 +42,7 @@ export {
14+ type MCPConnectionResult,
15+ type MCPDiscoverResult,
16+ type MCPServerOptions,
17++ MAX_SSE_AGE_MS,
18+ MCP_SERVER_ID_MAX_LENGTH,
19+ McpAgent,
20+ type McpAuthContext,
121 diff --git a/dist/mcp/index.js b/dist/mcp/index.js
2−index 1edcf0c8..e65cd346 100644
22+index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..0ba4f5bff8e6a968aa18c609952e576cedf13d8f 100644
323 --- a/dist/mcp/index.js
424 +++ b/dist/mcp/index.js
5−@@ -32,9 +32,9 @@ const KEEPALIVE_FRAME = ": keepalive\n\n";
25+@@ -28,13 +28,14 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
26+ const KEEPALIVE_INTERVAL_MS = 25e3;
27+ /** SSE comment frame the parser drops before any event dispatch. */
28+ const KEEPALIVE_FRAME = ": keepalive\n\n";
29++const MAX_SSE_AGE_MS = 300_000;
30+ /**
631 * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle
732 * that the stream cleanup must invoke when the stream closes.
833 */
@@ -14,7 +39,7 @@ index 1edcf0c8..e65cd346 100644 · 5 unmodified lines
1439 }, KEEPALIVE_INTERVAL_MS);
1540 return handle;
1641 }
17−@@ -180,10 +180,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
42+@@ -180,10 +181,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
1843 });
1944 return new Response(body, { status: 404 });
2045 }
@@ -34,7 +59,7 @@ index 1edcf0c8..e65cd346 100644 · 13 unmodified lines
3459 request.headers.forEach((value, key) => {
3560 existingHeaders[key] = value;
3661 });
37−@@ -206,45 +211,68 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
62+@@ -206,45 +212,71 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
3863 jsonrpc: "2.0"
3964 });
4065 return new Response(body, { status: 500 });
@@ -45,17 +70,20 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines
4570 - return new Response(null, {
4671 + }
4772 + ws.accept();
73++ const __closeSse = () => {
74++ __sseClosed = true;
75++ try {
76++ clearInterval(keepAlive);
77++ } catch {}
78++ try {
79++ ws.close(1013, "SSE client not draining");
80++ } catch {}
81++ writer.abort(new Error("SSE client not draining")).catch(() => {});
82++ };
4883 + const __forwardSse = (frame) => {
4984 + if (__sseClosed) return __writeChain;
5085 + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
51−+ __sseClosed = true;
52−+ try {
53−+ clearInterval(keepAlive);
54−+ } catch {}
55−+ try {
56−+ ws.close(1013, "SSE client not draining");
57−+ } catch {}
58−+ writer.abort(new Error("SSE client not draining")).catch(() => {});
86++ __closeSse();
5987 + return Promise.resolve();
6088 + }
6189 + __pendingBytes += frame.byteLength;
@@ -129,7 +157,7 @@ index 1edcf0c8..e65cd346 100644 · 67 unmodified lines
129157 }
130158 onClose().catch(console.error);
131159 });
132−@@ -279,10 +307,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
160+@@ -279,10 +311,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
133161 id: null,
134162 jsonrpc: "2.0"
135163 }), { status: 400 });
@@ -140,6 +168,7 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines
140168 + const { readable, writable } = new TransformStream();
141169 + const writer = writable.getWriter();
142170 + const encoder = new TextEncoder();
171++ const __openedAt = Date.now();
143172 + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
144173 + let __pendingBytes = 0;
145174 + let __writeChain = Promise.resolve();
@@ -149,7 +178,7 @@ index 1edcf0c8..e65cd346 100644 · 3 unmodified lines
149178 props: ctx.props,
150179 jurisdiction: options.jurisdiction
151180 });
152−@@ -306,27 +339,51 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
181+@@ -306,27 +344,86 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
153182 if (!ws) {
154183 await writer.close();
155184 return new Response("Failed to establish WS to DO", { status: 500 });
@@ -158,17 +187,52 @@ index 1edcf0c8..e65cd346 100644 · 2 unmodified lines
158187 - ws.addEventListener("message", (event) => {
159188 + }
160189 + ws.accept();
190++ const __abortSse = () => {
191++ __sseClosed = true;
192++ try {
193++ clearInterval(keepAlive);
194++ } catch {}
195++ try {
196++ ws.close(1013, "SSE client not draining");
197++ } catch {}
198++ writer.abort(new Error("SSE client not draining")).catch(() => {});
199++ };
200++ const __closeSseGracefully = () => {
201++ __sseClosed = true;
202++ try {
203++ clearInterval(keepAlive);
204++ } catch {}
205++ try {
206++ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");
207++ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {
208++ writer.abort().catch(() => {});
209++ });
210++ } catch {
211++ __writeChain = writer.close().catch(() => {
212++ writer.abort().catch(() => {});
213++ });
214++ }
215++ try {
216++ ws.close(1000, "sse_max_age_rotation");
217++ } catch {}
218++ return __writeChain;
219++ };
161220 + const __forwardSse = (frame) => {
162221 + if (__sseClosed) return __writeChain;
222++ const ageMs = Date.now() - __openedAt;
223++ if (ageMs > MAX_SSE_AGE_MS) {
224++ console.log(JSON.stringify({
225++ event: "sse_max_age_close",
226++ sessionId,
227++ variant: "streamable-get",
228++ ageMs,
229++ pendingBytes: __pendingBytes
230++ }));
231++ __closeSseGracefully();
232++ return Promise.resolve();
233++ }
163234 + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
164−+ __sseClosed = true;
165−+ try {
166−+ clearInterval(keepAlive);
167−+ } catch {}
168−+ try {
169−+ ws.close(1013, "SSE client not draining");
170−+ } catch {}
171−+ writer.abort(new Error("SSE client not draining")).catch(() => {});
235++ __abortSse();
172236 + return Promise.resolve();
173237 + }
174238 + __pendingBytes += frame.byteLength;
@@ -216,7 +280,7 @@ index 1edcf0c8..e65cd346 100644 · 41 unmodified lines
216280 return new Response(readable, {
217281 headers: {
218282 "Cache-Control": "no-cache",
219−@@ -389,10 +446,15 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
283+@@ -389,10 +486,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
220284 const url = new URL(request.url);
221285 if (request.method === "GET" && basePattern.test(url)) {
222286 const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();
@@ -227,6 +291,7 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines
227291 + const { readable, writable } = new TransformStream();
228292 + const writer = writable.getWriter();
229293 + const encoder = new TextEncoder();
294++ const __openedAt = Date.now();
230295 + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
231296 + let __pendingBytes = 0;
232297 + let __writeChain = Promise.resolve();
@@ -236,7 +301,7 @@ index 1edcf0c8..e65cd346 100644 · 3 unmodified lines
236301 endpointUrl.pathname = encodeURI(`${basePath}/message`);
237302 endpointUrl.searchParams.set("sessionId", sessionId);
238303 const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;
239−@@ -414,35 +476,59 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
304+@@ -414,35 +517,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
240305 console.error("Failed to establish WebSocket connection");
241306 await writer.close();
242307 return new Response("Failed to establish WebSocket connection", { status: 500 });
@@ -245,17 +310,52 @@ index 1edcf0c8..e65cd346 100644 · 2 unmodified lines
245310 - ws.addEventListener("message", (event) => {
246311 + }
247312 + ws.accept();
313++ const __abortSse = () => {
314++ __sseClosed = true;
315++ try {
316++ clearInterval(keepAlive);
317++ } catch {}
318++ try {
319++ ws.close(1013, "SSE client not draining");
320++ } catch {}
321++ writer.abort(new Error("SSE client not draining")).catch(() => {});
322++ };
323++ const __closeSseGracefully = () => {
324++ __sseClosed = true;
325++ try {
326++ clearInterval(keepAlive);
327++ } catch {}
328++ try {
329++ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");
330++ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {
331++ writer.abort().catch(() => {});
332++ });
333++ } catch {
334++ __writeChain = writer.close().catch(() => {
335++ writer.abort().catch(() => {});
336++ });
337++ }
338++ try {
339++ ws.close(1000, "sse_max_age_rotation");
340++ } catch {}
341++ return __writeChain;
342++ };
248343 + const __forwardSse = (frame) => {
249344 + if (__sseClosed) return __writeChain;
345++ const ageMs = Date.now() - __openedAt;
346++ if (ageMs > MAX_SSE_AGE_MS) {
347++ console.log(JSON.stringify({
348++ event: "sse_max_age_close",
349++ sessionId,
350++ variant: "legacy-sse",
351++ ageMs,
352++ pendingBytes: __pendingBytes
353++ }));
354++ __closeSseGracefully();
355++ return Promise.resolve();
356++ }
250357 + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
251−+ __sseClosed = true;
252−+ try {
253−+ clearInterval(keepAlive);
254−+ } catch {}
255−+ try {
256−+ ws.close(1013, "SSE client not draining");
257−+ } catch {}
258−+ writer.abort(new Error("SSE client not draining")).catch(() => {});
358++ __abortSse();
259359 + return Promise.resolve();
260360 + }
261361 + __pendingBytes += frame.byteLength;
@@ -314,10 +414,11 @@ index 1edcf0c8..e65cd346 100644 · 52 unmodified lines
314414 console.error("Error closing SSE connection:", error);
315415 }
316416 }
317−@@ -1700,4 +1786,4 @@ McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
417+@@ -1698,6 +1860,6 @@ var McpAgent = class McpAgent extends Agent {
418+ };
419+ McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
318420 //#endregion
319− export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
421+-export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
422++export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
320423
321−-//# sourceMappingURL=index.js.map
322−\ No newline at end of file
323−+//# sourceMappingURL=index.js.map
424+ //# sourceMappingURL=index.js.map
scripts/install.sh+1−1
@@ -1,7 +1,7 @@
11 #!/usr/bin/env bash
22 set -euo pipefail
33 APP=executor
4−REPO=RhysSullivan/executor
4+REPO=UsefulSoftwareCo/executor
55
66 MUTED='\033[0;2m'
77 RED='\033[0;31m'
scripts/pull-references.ts+1−1
@@ -8,7 +8,7 @@ const REFERENCE_DIR = join(import.meta.dirname, "../.reference"); · 7 unmodified lines
88 const repos = [
99 { name: "effect", url: "https://github.com/Effect-TS/effect.git" },
1010 { 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" },
1212 {
1313 name: "tanstack-router",
1414 url: "https://github.com/TanStack/router.git",