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 @@
1{1{
2 "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",2 "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
3 "changelog": ["@changesets/changelog-github", { "repo": "RhysSullivan/executor" }],3 "changelog": ["@changesets/changelog-github", { "repo": "UsefulSoftwareCo/executor" }],
4 "commit": false,4 "commit": false,
5 "fixed": [5 "fixed": [
6 [6 [
.github/ISSUE_TEMPLATE/desktop-crash.yml+1−1
@@ -7,7 +7,7 @@ body: · 6 unmodified lines
7 attributes:7 attributes:
8 value: |8 value: |
9 Before filing, please try the quick fixes in the pinned9 Before filing, please try the quick fixes in the pinned
10 **[Desktop app crashing? Start here](https://github.com/RhysSullivan/executor/issues?q=is%3Aissue+label%3Acrash-triage)**10 **[Desktop app crashing? Start here](https://github.com/UsefulSoftwareCo/executor/issues?q=is%3Aissue+label%3Acrash-triage)**
11 guide — most crashes are fixed by updating to the latest version.11 guide — most crashes are fixed by updating to the latest version.
12 If you're still stuck, the details below help us pin it down fast.12 If you're still stuck, the details below help us pin it down fast.
13 - type: input13 - type: input
.github/workflows/ci.yml+39−13
@@ -13,7 +13,7 @@ concurrency: · 12 unmodified lines
13jobs:13jobs:
14 format:14 format:
15 name: Format15 name: Format
16 runs-on: ubuntu-latest16 runs-on: blacksmith-4vcpu-ubuntu-2404
17 steps:17 steps:
18 - uses: actions/checkout@v418 - uses: actions/checkout@v4
19 19
@@ -29,13 +29,20 @@ jobs: · 9 unmodified lines
29 restore-keys: |29 restore-keys: |
30 ${{ runner.os }}-bun-1.3.11-30 ${{ runner.os }}-bun-1.3.11-
31 31
32 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
33 # builds it from source via node-gyp, whose undici needs Node 22.10+
34 # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
35 - uses: actions/setup-node@v4
36 with:
37 node-version: 22
38
32 - run: bun install --frozen-lockfile39 - run: bun install --frozen-lockfile
33 40
34 - run: bun run format:check41 - run: bun run format:check
35 42
36 lint:43 lint:
37 name: Lint44 name: Lint
38 runs-on: ubuntu-latest45 runs-on: blacksmith-4vcpu-ubuntu-2404
39 steps:46 steps:
40 - uses: actions/checkout@v447 - uses: actions/checkout@v4
41 48
@@ -51,13 +58,20 @@ jobs: · 9 unmodified lines
51 restore-keys: |58 restore-keys: |
52 ${{ runner.os }}-bun-1.3.11-59 ${{ runner.os }}-bun-1.3.11-
53 60
61 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
62 # builds it from source via node-gyp, whose undici needs Node 22.10+
63 # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
64 - uses: actions/setup-node@v4
65 with:
66 node-version: 22
67
54 - run: bun install --frozen-lockfile68 - run: bun install --frozen-lockfile
55 69
56 - run: bun run lint70 - run: bun run lint
57 71
58 typecheck:72 typecheck:
59 name: Typecheck73 name: Typecheck
60 runs-on: ubuntu-latest74 runs-on: blacksmith-4vcpu-ubuntu-2404
61 steps:75 steps:
62 - uses: actions/checkout@v476 - uses: actions/checkout@v4
63 77
@@ -73,13 +87,20 @@ jobs: · 9 unmodified lines
73 restore-keys: |87 restore-keys: |
74 ${{ runner.os }}-bun-1.3.11-88 ${{ runner.os }}-bun-1.3.11-
75 89
90 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
91 # builds it from source via node-gyp, whose undici needs Node 22.10+
92 # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
93 - uses: actions/setup-node@v4
94 with:
95 node-version: 22
96
76 - run: bun install --frozen-lockfile97 - run: bun install --frozen-lockfile
77 98
78 - run: bun run typecheck99 - run: bun run typecheck
79 100
80 test:101 test:
81 name: Test102 name: Test
82 runs-on: ubuntu-latest103 runs-on: blacksmith-4vcpu-ubuntu-2404
83 env:104 env:
84 TURBO_TEST_CONCURRENCY: 3105 TURBO_TEST_CONCURRENCY: 3
85 steps:106 steps:
@@ -130,7 +151,7 @@ jobs: · 44 unmodified lines
130 - { target: cloud, shard: 7/8, shard-name: 7of8 }151 - { target: cloud, shard: 7/8, shard-name: 7of8 }
131 - { target: cloud, shard: 8/8, shard-name: 8of8 }152 - { target: cloud, shard: 8/8, shard-name: 8of8 }
132 - target: selfhost153 - target: selfhost
133 runs-on: ubuntu-latest154 runs-on: blacksmith-4vcpu-ubuntu-2404
134 timeout-minutes: 30155 timeout-minutes: 30
135 steps:156 steps:
136 - uses: actions/checkout@v4157 - uses: actions/checkout@v4
@@ -191,7 +212,7 @@ jobs: · 54 unmodified lines
191 # Skipped on pull_request: the local scenario boots a real `executor web`212 # Skipped on pull_request: the local scenario boots a real `executor web`
192 # plus a browser and is currently flaky on PRs. Still runs on push to main.213 # plus a browser and is currently flaky on PRs. Still runs on push to main.
193 if: github.event_name != 'pull_request'214 if: github.event_name != 'pull_request'
194 runs-on: ubuntu-latest215 runs-on: blacksmith-4vcpu-ubuntu-2404
195 timeout-minutes: 20216 timeout-minutes: 20
196 steps:217 steps:
197 - uses: actions/checkout@v4218 - uses: actions/checkout@v4
@@ -244,7 +265,7 @@ jobs: · 46 unmodified lines
244 265
245 desktop-smoke:266 desktop-smoke:
246 name: Desktop smoke build267 name: Desktop smoke build
247 runs-on: ubuntu-latest268 runs-on: blacksmith-4vcpu-ubuntu-2404
248 steps:269 steps:
249 - uses: actions/checkout@v4270 - uses: actions/checkout@v4
250 271
@@ -260,6 +281,13 @@ jobs: · 9 unmodified lines
260 restore-keys: |281 restore-keys: |
261 ${{ runner.os }}-bun-1.3.11-282 ${{ runner.os }}-bun-1.3.11-
262 283
284 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
285 # builds it from source via node-gyp, whose undici needs Node 22.10+
286 # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
287 - uses: actions/setup-node@v4
288 with:
289 node-version: 22
290
263 - run: bun install --frozen-lockfile291 - run: bun install --frozen-lockfile
264 292
265 - name: Build web app293 - name: Build web app
@@ -277,19 +305,17 @@ jobs: · 11 unmodified lines
277 305
278 selfhost-docker-smoke:306 selfhost-docker-smoke:
279 name: Self-host Docker image307 name: Self-host Docker image
280 runs-on: ubuntu-latest308 runs-on: blacksmith-4vcpu-ubuntu-2404
281 steps:309 steps:
282 - uses: actions/checkout@v4310 - uses: actions/checkout@v4
283 311
284 - name: Set up Docker Buildx312 - name: Setup Blacksmith Builder
285 uses: docker/setup-buildx-action@v3313 uses: useblacksmith/setup-docker-builder@v1
286 314
287 - name: Build self-host image315 - name: Build self-host image
288 uses: docker/build-push-action@v6316 uses: useblacksmith/build-push-action@v2
289 with:317 with:
290 context: .318 context: .
291 file: apps/host-selfhost/Dockerfile319 file: apps/host-selfhost/Dockerfile
292 push: false320 push: false
293 tags: executor-selfhost:ci321 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
64 publish:64 publish:
65 name: Publish65 name: Publish
66 needs: build-preview-binary66 needs: build-preview-binary
67 runs-on: ubuntu-latest67 runs-on: blacksmith-4vcpu-ubuntu-2404
68 permissions:68 permissions:
69 contents: read69 contents: read
70 pull-requests: write70 pull-requests: write
@@ -77,6 +77,13 @@ jobs: · 6 unmodified lines
77 with:77 with:
78 bun-version: 1.3.1178 bun-version: 1.3.11
79 79
80 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
81 # builds it from source via node-gyp, whose undici needs Node 22.10+
82 # (webidl.markAsUncloneable). Pin the same runtime the CI jobs use.
83 - uses: actions/setup-node@v4
84 with:
85 node-version: 22
86
80 - run: bun install --frozen-lockfile87 - run: bun install --frozen-lockfile
81 88
82 - name: Build executor preview wrapper89 - name: Build executor preview wrapper
.github/workflows/preview-sweep.yml+1−1
@@ -19,7 +19,7 @@ permissions: · 18 unmodified lines
19jobs:19jobs:
20 sweep:20 sweep:
21 name: Destroy previews for closed PRs21 name: Destroy previews for closed PRs
22 runs-on: ubuntu-latest22 runs-on: blacksmith-4vcpu-ubuntu-2404
23 steps:23 steps:
24 - uses: actions/checkout@v424 - uses: actions/checkout@v4
25 25
.github/workflows/preview.yml+9−2
@@ -27,7 +27,7 @@ jobs: · 26 unmodified lines
27 deploy:27 deploy:
28 name: Deploy preview28 name: Deploy preview
29 if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository29 if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
30 runs-on: ubuntu-latest30 runs-on: blacksmith-4vcpu-ubuntu-2404
31 steps:31 steps:
32 - uses: actions/checkout@v432 - uses: actions/checkout@v4
33 33
@@ -35,6 +35,13 @@ jobs: · 1 unmodified line
35 with:35 with:
36 bun-version: 1.3.1136 bun-version: 1.3.11
37 37
38 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
39 # builds it from source via node-gyp, whose undici needs Node 22.10+
40 # (webidl.markAsUncloneable). Pin the same runtime the CI jobs use.
41 - uses: actions/setup-node@v4
42 with:
43 node-version: 22
44
38 - run: bun install --frozen-lockfile45 - run: bun install --frozen-lockfile
39 46
40 - name: Deploy47 - name: Deploy
@@ -82,7 +89,7 @@ jobs: · 41 unmodified lines
82 teardown:89 teardown:
83 name: Tear down preview90 name: Tear down preview
84 if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository91 if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
85 runs-on: ubuntu-latest92 runs-on: blacksmith-4vcpu-ubuntu-2404
86 steps:93 steps:
87 - uses: actions/checkout@v494 - uses: actions/checkout@v4
88 95
.github/workflows/publish-desktop.yml+1−1
@@ -177,7 +177,7 @@ jobs: · 176 unmodified lines
177 177
178 release:178 release:
179 needs: build179 needs: build
180 runs-on: ubuntu-latest180 runs-on: blacksmith-4vcpu-ubuntu-2404
181 permissions:181 permissions:
182 contents: write182 contents: write
183 183
.github/workflows/publish-executor-package.yml+1−1
@@ -21,7 +21,7 @@ concurrency: · 20 unmodified lines
21 21
22jobs:22jobs:
23 publish:23 publish:
24 runs-on: ubuntu-latest24 runs-on: blacksmith-4vcpu-ubuntu-2404
25 permissions:25 permissions:
26 actions: write26 actions: write
27 contents: write27 contents: write
.github/workflows/publish-selfhost-docker.yml+42−4
@@ -18,10 +18,12 @@ concurrency: · 17 unmodified lines
18 18
19jobs:19jobs:
20 publish:20 publish:
21 runs-on: ubuntu-latest21 runs-on: blacksmith-4vcpu-ubuntu-2404
22 permissions:22 permissions:
23 contents: read23 contents: read
24 packages: write24 packages: write
25 env:
26 HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
25 27
26 steps:28 steps:
27 - name: Checkout29 - name: Checkout
@@ -57,6 +59,7 @@ jobs: · 29 unmodified lines
57 version="${RELEASE_TAG#v}"59 version="${RELEASE_TAG#v}"
58 owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"60 owner="$(printf '%s' "$GITHUB_REPOSITORY_OWNER" | tr '[:upper:]' '[:lower:]')"
59 image="ghcr.io/${owner}/executor-selfhost"61 image="ghcr.io/${owner}/executor-selfhost"
62 legacy_image="ghcr.io/rhyssullivan/executor-selfhost"
60 revision="$(git rev-parse HEAD)"63 revision="$(git rev-parse HEAD)"
61 64
62 if [[ "$version" == *-* ]]; then65 if [[ "$version" == *-* ]]; then
@@ -74,6 +77,11 @@ jobs: · 11 unmodified lines
74 echo "${image}:${version}"77 echo "${image}:${version}"
75 echo "${image}:${channel}"78 echo "${image}:${channel}"
76 echo "TAGS"79 echo "TAGS"
80 echo "legacy_tags<<TAGS"
81 echo "${legacy_image}:${RELEASE_TAG}"
82 echo "${legacy_image}:${version}"
83 echo "${legacy_image}:${channel}"
84 echo "TAGS"
77 echo "labels<<LABELS"85 echo "labels<<LABELS"
78 echo "org.opencontainers.image.title=Executor self-host"86 echo "org.opencontainers.image.title=Executor self-host"
79 echo "org.opencontainers.image.description=Single-container self-hosted Executor"87 echo "org.opencontainers.image.description=Single-container self-hosted Executor"
@@ -86,8 +94,8 @@ jobs: · 6 unmodified lines
86 - name: Set up QEMU94 - name: Set up QEMU
87 uses: docker/setup-qemu-action@v395 uses: docker/setup-qemu-action@v3
88 96
89 - name: Set up Docker Buildx97 - name: Setup Blacksmith Builder
90 uses: docker/setup-buildx-action@v398 uses: useblacksmith/setup-docker-builder@v1
91 99
92 - name: Log in to GHCR100 - name: Log in to GHCR
93 uses: docker/login-action@v3101 uses: docker/login-action@v3
@@ -97,7 +105,8 @@ jobs: · 3 unmodified lines
97 password: ${{ secrets.GITHUB_TOKEN }}105 password: ${{ secrets.GITHUB_TOKEN }}
98 106
99 - name: Build and push self-host image107 - name: Build and push self-host image
100 uses: docker/build-push-action@v6108 id: build
109 uses: useblacksmith/build-push-action@v2
101 with:110 with:
102 context: .111 context: .
103 file: apps/host-selfhost/Dockerfile112 file: apps/host-selfhost/Dockerfile
@@ -105,3 +114,32 @@ jobs: · 1 unmodified line
105 push: true114 push: true
106 tags: ${{ steps.image.outputs.tags }}115 tags: ${{ steps.image.outputs.tags }}
107 labels: ${{ steps.image.outputs.labels }}116 labels: ${{ steps.image.outputs.labels }}
117
118 - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)
119 if: env.HAS_LEGACY_TOKEN != 'true'
120 run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."
121
122 - name: Log in to legacy GHCR namespace
123 if: env.HAS_LEGACY_TOKEN == 'true'
124 uses: docker/login-action@v3
125 with:
126 registry: ghcr.io
127 username: rhyssullivan
128 password: ${{ secrets.GHCR_LEGACY_TOKEN }}
129
130 - name: Mirror self-host image to legacy GHCR namespace
131 if: env.HAS_LEGACY_TOKEN == 'true'
132 shell: bash
133 env:
134 SOURCE_IMAGE: ${{ steps.image.outputs.image }}@${{ steps.build.outputs.digest }}
135 LEGACY_TAGS: ${{ steps.image.outputs.legacy_tags }}
136 run: |
137 set -euo pipefail
138
139 tag_args=()
140 while IFS= read -r tag; do
141 [[ -n "$tag" ]] || continue
142 tag_args+=("-t" "$tag")
143 done <<< "$LEGACY_TAGS"
144
145 docker buildx imagetools create "${tag_args[@]}" "$SOURCE_IMAGE"
.github/workflows/release.yml+1−1
@@ -15,7 +15,7 @@ concurrency: · 14 unmodified lines
15 15
16jobs:16jobs:
17 release:17 release:
18 runs-on: ubuntu-latest18 runs-on: blacksmith-4vcpu-ubuntu-2404
19 permissions:19 permissions:
20 actions: write20 actions: write
21 contents: write21 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
104- **Never commit until the owner explicitly says so.** Set everything up in the working tree, run `git status`, and stop.104- **Never commit until the owner explicitly says so.** Set everything up in the working tree, run `git status`, and stop.
105- **No AI / Claude / Anthropic / Co-Authored-By trailers** in commits, commit messages, PRs, or any generated file. This is in `CLAUDE.md` — do not violate.105- **No AI / Claude / Anthropic / Co-Authored-By trailers** in commits, commit messages, PRs, or any generated file. This is in `CLAUDE.md` — do not violate.
106- **Branch naming**: `rs/<short-topic>` for Rhys's branches. Beta-start branch: `rs/beta-v<version>-start`.106- **Branch naming**: `rs/<short-topic>` for Rhys's branches. Beta-start branch: `rs/beta-v<version>-start`.
107- **Remote**: `origin` = `https://github.com/RhysSullivan/executor.git`. If another remote appears (e.g. a fork remote), ask whether to remove it.107- **Remote**: `origin` = `https://github.com/UsefulSoftwareCo/executor.git`. If another remote appears (e.g. a fork remote), ask whether to remove it.
108- **Dirty working tree**: if there are uncommitted changes when starting a release, ask whether to include them, stash them, or commit separately first. Don't sweep them into the release commit silently.108- **Dirty working tree**: if there are uncommitted changes when starting a release, ask whether to include them, stash them, or commit separately first. Don't sweep them into the release commit silently.
109- **Don't estimate time** — code is cheap to write. Focus on what to do, not how long it takes.109- **Don't estimate time** — code is cheap to write. Focus on what to do, not how long it takes.
110- **Fact-check scope claims** before publishing. If release notes say "does not affect X", verify by reading the diff.110- **Fact-check scope claims** before publishing. If release notes say "does not affect X", verify by reading the diff.
README.md+1−1
@@ -225,7 +225,7 @@ The browser e2e scenarios need Playwright's Chromium once per machine: · 224 unmodified lines
225## Community225## Community
226 226
227Join the [Discord](https://discord.gg/eF29HBHwM6). To learn more, visit227Join the [Discord](https://discord.gg/eF29HBHwM6). To learn more, visit
228[executor.sh](https://executor.sh) or [Ask DeepWiki](https://deepwiki.com/RhysSullivan/executor).228[executor.sh](https://executor.sh) or [Ask DeepWiki](https://deepwiki.com/UsefulSoftwareCo/executor).
229 229
230## License230## License
231 231
RELEASING.md+1−1
@@ -24,7 +24,7 @@ the self-host Docker image. · 23 unmodified lines
24 - creates or updates the GitHub release with build artifacts24 - creates or updates the GitHub release with build artifacts
25 - dispatches `.github/workflows/publish-desktop.yml`25 - dispatches `.github/workflows/publish-desktop.yml`
26 - dispatches `.github/workflows/publish-selfhost-docker.yml`26 - dispatches `.github/workflows/publish-selfhost-docker.yml`
276. The self-host Docker workflow publishes `ghcr.io/rhyssullivan/executor-selfhost`276. The self-host Docker workflow publishes `ghcr.io/usefulsoftwareco/executor-selfhost`
28 for `linux/amd64` and `linux/arm64`:28 for `linux/amd64` and `linux/arm64`:
29 - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest`29 - stable releases get `vX.Y.Z`, `X.Y.Z`, and `latest`
30 - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta`30 - prereleases get `vX.Y.Z-...`, `X.Y.Z-...`, and `beta`
apps/cli/src/service.test.ts+76−6
@@ -1,4 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
2 5
3import {6import {
4 cmdSetValue,7 cmdSetValue,
@@ -12,6 +15,14 @@ import { · 7 unmodified lines
12 parseSchtasksRunning,15 parseSchtasksRunning,
13} from "./service";16} from "./service";
14 17
18const tempDirs: Array<string> = [];
19
20afterEach(() => {
21 for (const dir of tempDirs.splice(0)) {
22 rmSync(dir, { recursive: true, force: true });
23 }
24});
25
15describe("service unit generation", () => {26describe("service unit generation", () => {
16 const launchdInput = {27 const launchdInput = {
17 label: "sh.executor.daemon",28 label: "sh.executor.daemon",
@@ -36,16 +47,49 @@ describe("service unit generation", () => { · 18 unmodified lines
36 workingDirectory: "/Users/x/.executor",47 workingDirectory: "/Users/x/.executor",
37 };48 };
38 49
50 const makeExecutorBundle = (bundleIdentifier: string): string => {
51 const root = mkdtempSync(join(tmpdir(), "executor-launchd-bundle-"));
52 tempDirs.push(root);
53 const contentsDir = join(root, "Executor.app", "Contents");
54 const executableDir = join(contentsDir, "Resources", "executor");
55 mkdirSync(executableDir, { recursive: true });
56 writeFileSync(
57 join(contentsDir, "Info.plist"),
58 `<?xml version="1.0" encoding="UTF-8"?>
59<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
60<plist version="1.0">
61<dict>
62 <key>CFBundleIdentifier</key>
63 <string>${bundleIdentifier}</string>
64</dict>
65</plist>
66`,
67 );
68 return join(executableDir, "executor");
69 };
70
71 const expectLaunchdInvariants = (plist: string, input: typeof launchdInput): void => {
72 const programArguments = input.programArguments
73 .map((arg) => ` <string>${arg}</string>`)
74 .join("\n");
75
76 expect(plist).toContain("<key>Label</key>");
77 expect(plist).toContain(`<string>${input.label}</string>`);
78 expect(plist).toContain(
79 ` <key>ProgramArguments</key>\n <array>\n${programArguments}\n </array>`,
80 );
81 expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/);
82 expect(plist).toMatch(
83 /<key>KeepAlive<\/key>\s*<dict>\s*<key>SuccessfulExit<\/key>\s*<false\/>\s*<\/dict>/,
84 );
85 };
86
39 it("renders a launchd plist that restarts on crash but not clean stop", () => {87 it("renders a launchd plist that restarts on crash but not clean stop", () => {
40 const plist = generateLaunchdPlist(launchdInput);88 const plist = generateLaunchdPlist(launchdInput);
89 expectLaunchdInvariants(plist, launchdInput);
41 expect(plist).toContain('<plist version="1.0">');90 expect(plist).toContain('<plist version="1.0">');
42 expect(plist).toContain("<key>Label</key>");
43 expect(plist).toContain("<string>sh.executor.daemon</string>");
44 expect(plist).toMatch(/<key>RunAtLoad<\/key>\s*<true\/>/);
45 // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout.91 // KeepAlive => restart only on non-zero/crash exit, not on a clean bootout.
46 expect(plist).toContain("<key>KeepAlive</key>");
47 expect(plist).toContain("<key>SuccessfulExit</key>");92 expect(plist).toContain("<key>SuccessfulExit</key>");
48 expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/);
49 expect(plist).toContain("<key>ProcessType</key>");93 expect(plist).toContain("<key>ProcessType</key>");
50 expect(plist).toContain("<string>Background</string>");94 expect(plist).toContain("<string>Background</string>");
51 expect(plist).toContain("--foreground");95 expect(plist).toContain("--foreground");
@@ -59,6 +103,32 @@ describe("service unit generation", () => { · 7 unmodified lines
59 expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD");103 expect(plist).not.toContain("EXECUTOR_AUTH_PASSWORD");
60 });104 });
61 105
106 it("associates an app-bundled launchd plist with the enclosing bundle identifier", () => {
107 const executablePath = makeExecutorBundle("sh.executor.desktop.test");
108 const input = {
109 ...launchdInput,
110 programArguments: [executablePath, ...launchdInput.programArguments.slice(1)],
111 };
112 const plist = generateLaunchdPlist(input);
113
114 expectLaunchdInvariants(plist, input);
115 expect(plist.match(/<key>AssociatedBundleIdentifiers<\/key>/g)).toHaveLength(1);
116 expect(plist).toMatch(
117 /<dict>\s*<key>Label<\/key>\s*<string>sh\.executor\.daemon<\/string>\s*<key>AssociatedBundleIdentifiers<\/key>\s*<array>\s*<string>sh\.executor\.desktop\.test<\/string>\s*<\/array>\s*<key>ProgramArguments<\/key>/,
118 );
119 });
120
121 it("omits app association for standalone launchd binaries", () => {
122 const input = {
123 ...launchdInput,
124 programArguments: ["/usr/local/bin/executor", ...launchdInput.programArguments.slice(1)],
125 };
126 const plist = generateLaunchdPlist(input);
127
128 expectLaunchdInvariants(plist, input);
129 expect(plist).not.toContain("AssociatedBundleIdentifiers");
130 });
131
62 it("xml-escapes environment values", () => {132 it("xml-escapes environment values", () => {
63 const plist = generateLaunchdPlist({133 const plist = generateLaunchdPlist({
64 ...launchdInput,134 ...launchdInput,
apps/cli/src/service.ts+45−2
@@ -1,4 +1,5 @@
1import { execFile } from "node:child_process";1import { execFile } from "node:child_process";
2import { readFileSync } from "node:fs";
2import { homedir, userInfo } from "node:os";3import { homedir, userInfo } from "node:os";
3import { FileSystem, Path } from "effect";4import { FileSystem, Path } from "effect";
4import type { PlatformError } from "effect/PlatformError";5import type { PlatformError } from "effect/PlatformError";
@@ -143,6 +144,14 @@ const xmlEscape = (value: string): string => · 138 unmodified lines
143 .replaceAll('"', "&quot;")144 .replaceAll('"', "&quot;")
144 .replaceAll("'", "&apos;");145 .replaceAll("'", "&apos;");
145 146
147const xmlUnescape = (value: string): string =>
148 value
149 .replaceAll("&apos;", "'")
150 .replaceAll("&quot;", '"')
151 .replaceAll("&gt;", ">")
152 .replaceAll("&lt;", "<")
153 .replaceAll("&amp;", "&");
154
146// ---------------------------------------------------------------------------155// ---------------------------------------------------------------------------
147// Shared service environment + program args156// Shared service environment + program args
148// ---------------------------------------------------------------------------157// ---------------------------------------------------------------------------
@@ -209,6 +218,36 @@ const launchdPlistPath = (path: Path.Path): string => · 60 unmodified lines
209 218
210const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");219const serviceLogDir = (path: Path.Path): string => path.join(resolveExecutorDataDir(path), "logs");
211 220
221const DESKTOP_APP_BUNDLE_IDENTIFIER = "sh.executor.desktop";
222
223const enclosingAppBundlePath = (executablePath: string): string | null => {
224 const match = executablePath.match(/^(.*\.app)\/Contents\//);
225 return match?.[1] ?? null;
226};
227
228const 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
239const 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
212export interface LaunchdPlistOptions {251export interface LaunchdPlistOptions {
213 readonly label: string;252 readonly label: string;
214 readonly programArguments: ReadonlyArray<string>;253 readonly programArguments: ReadonlyArray<string>;
@@ -219,7 +258,7 @@ export interface LaunchdPlistOptions { · 4 unmodified lines
219}258}
220 259
221/**260/**
222 * Render a user LaunchAgent plist. Pure (snapshot-tested). KeepAlive uses261 * Render a user LaunchAgent plist. KeepAlive uses
223 * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero262 * `SuccessfulExit=false` so launchd restarts the daemon on a crash/non-zero
224 * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →263 * exit but leaves it stopped after a clean `bootout` (which sends SIGTERM →
225 * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background264 * the daemon exits 0). RunAtLoad starts it on login; ProcessType=Background
@@ -229,6 +268,10 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 3 unmodified lines
229 const programArgs = options.programArguments268 const programArgs = options.programArguments
230 .map((arg) => ` <string>${xmlEscape(arg)}</string>`)269 .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
231 .join("\n");270 .join("\n");
271 const bundleIdentifier = associatedBundleIdentifier(options.programArguments);
272 const associatedBundleIdentifiers = bundleIdentifier
273 ? ` <key>AssociatedBundleIdentifiers</key>\n <array>\n <string>${xmlEscape(bundleIdentifier)}</string>\n </array>\n`
274 : "";
232 const envEntries = Object.entries(options.environment)275 const envEntries = Object.entries(options.environment)
233 .map(276 .map(
234 ([key, value]) =>277 ([key, value]) =>
@@ -241,7 +284,7 @@ export const generateLaunchdPlist = (options: LaunchdPlistOptions): string => { · 6 unmodified lines
241<dict>284<dict>
242 <key>Label</key>285 <key>Label</key>
243 <string>${xmlEscape(options.label)}</string>286 <string>${xmlEscape(options.label)}</string>
244 <key>ProgramArguments</key>287${associatedBundleIdentifiers} <key>ProgramArguments</key>
245 <array>288 <array>
246${programArgs}289${programArgs}
247 </array>290 </array>
apps/cloud/drizzle/0008_slow_mephisto.sql+1−0
@@ -0,0 +1 @@
1ALTER 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 @@
1{1{
2 "id": "0a228326-fd03-4910-8506-64b57af52736",2 "id": "a0208f9c-622b-4c94-bd09-33bcf37c2462",
3 "prevId": "2f557ed1-5afc-431b-8623-2a2162793f43",3 "prevId": "2f557ed1-5afc-431b-8623-2a2162793f43",
4 "version": "7",4 "version": "7",
5 "dialect": "postgresql",5 "dialect": "postgresql",
@@ -245,12 +245,6 @@ · 239 unmodified lines
245 "primaryKey": false,245 "primaryKey": false,
246 "notNull": false246 "notNull": false
247 },247 },
248 "last_health": {
249 "name": "last_health",
250 "type": "json",
251 "primaryKey": false,
252 "notNull": false
253 },
254 "tools_synced_at": {248 "tools_synced_at": {
255 "name": "tools_synced_at",249 "name": "tools_synced_at",
256 "type": "bigint",250 "type": "bigint",
@@ -531,24 +525,18 @@ · 274 unmodified lines
531 "primaryKey": false,525 "primaryKey": false,
532 "notNull": false526 "notNull": false
533 },527 },
534 "config": {528 "config_revised_at": {
535 "name": "config",529 "name": "config_revised_at",
536 "type": "json",530 "type": "bigint",
537 "primaryKey": false,531 "primaryKey": false,
538 "notNull": false532 "notNull": false
539 },533 },
540 "health_check": {534 "config": {
541 "name": "health_check",535 "name": "config",
542 "type": "json",536 "type": "json",
543 "primaryKey": false,537 "primaryKey": false,
544 "notNull": false538 "notNull": false
545 },539 },
546 "config_revised_at": {
547 "name": "config_revised_at",
548 "type": "bigint",
549 "primaryKey": false,
550 "notNull": false
551 },
552 "can_remove": {540 "can_remove": {
553 "name": "can_remove",541 "name": "can_remove",
554 "type": "boolean",542 "type": "boolean",
@@ -676,6 +664,12 @@ · 121 unmodified lines
676 "primaryKey": false,664 "primaryKey": false,
677 "notNull": false665 "notNull": false
678 },666 },
667 "origin_issuer": {
668 "name": "origin_issuer",
669 "type": "text",
670 "primaryKey": false,
671 "notNull": false
672 },
679 "created_at": {673 "created_at": {
680 "name": "created_at",674 "name": "created_at",
681 "type": "timestamp",675 "type": "timestamp",
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
61 {61 {
62 "idx": 8,62 "idx": 8,
63 "version": "7",63 "version": "7",
64 "when": 1782957914937,64 "when": 1783025157232,
65 "tag": "0008_true_lake",65 "tag": "0008_slow_mephisto",
66 "breakpoints": true
67 },
68 {
69 "idx": 9,
70 "version": "7",
71 "when": 1783025157233,
72 "tag": "0009_true_lake",
66 "breakpoints": true73 "breakpoints": true
67 }74 }
68 ]75 ]
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
20import {
21 classifyOAuthClientGc,
22 isDcrClassifiedRow,
23 registrableOriginOfUrl,
24} from "@executor-js/sdk";
25
26import type { CodeMigration, CodeMigrationContext } from "./runner";
27
28interface 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
39const 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
56interface ConnectionCountRow {
57 readonly tenant: string;
58 readonly oauth_client_owner: string;
59 readonly oauth_client: string;
60 readonly count: string | number;
61}
62
63const 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. */
69const 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
88export 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 @@
1import type { CodeMigration } from "./runner";1import type { CodeMigration } from "./runner";
2import { googleOpenApiR2BlobMigration } from "./google-openapi-r2-blobs";2import { googleOpenApiR2BlobMigration } from "./google-openapi-r2-blobs";
3import { gcDeadDcrOAuthClientsMigration } from "./gc-dead-dcr-oauth-clients";
3 4
4export interface CloudCodeMigrationRegistryOptions {5export interface CloudCodeMigrationRegistryOptions {
5 readonly r2Bucket?: string;6 readonly r2Bucket?: string;
@@ -11,6 +12,9 @@ export const cloudCodeMigrations = ({ · 5 unmodified lines
11 limit,12 limit,
12}: CloudCodeMigrationRegistryOptions): readonly CodeMigration[] => [13}: CloudCodeMigrationRegistryOptions): readonly CodeMigration[] => [
13 ...(r2Bucket ? [googleOpenApiR2BlobMigration({ bucket: r2Bucket, limit })] : []),14 ...(r2Bucket ? [googleOpenApiR2BlobMigration({ bucket: r2Bucket, limit })] : []),
15 // GC dead DCR oauth_client rows + backfill surviving rows' origin_issuer
16 // (issue #1120, Part C). No R2/bucket dependency, so always registered.
17 gcDeadDcrOAuthClientsMigration,
14];18];
15 19
16export { runCodeMigrations } from "./runner";20export { runCodeMigrations } from "./runner";
apps/cloud/src/db/executor-schema.ts+1−0
@@ -85,6 +85,7 @@ export const oauth_client = pgTable( · 84 unmodified lines
85 resource: text("resource"),85 resource: text("resource"),
86 origin_kind: text("origin_kind"),86 origin_kind: text("origin_kind"),
87 origin_integration: text("origin_integration"),87 origin_integration: text("origin_integration"),
88 origin_issuer: text("origin_issuer"),
88 created_at: timestamp("created_at").notNull(),89 created_at: timestamp("created_at").notNull(),
89 row_id: varchar("row_id", { length: 255 })90 row_id: varchar("row_id", { length: 255 })
90 .primaryKey()91 .primaryKey()
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
3import { describe, expect, it } from "@effect/vitest";
4import { Effect } from "effect";
5
6import { gcDeadDcrOAuthClientsMigration } from "../../scripts/code-migrations/gc-dead-dcr-oauth-clients";
7import 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
14interface 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
25const 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
80const 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
91const 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
99describe("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
27import * as Sentry from "@sentry/cloudflare";
28import { Data, Effect } from "effect";
29import type * as Cause from "effect/Cause";
30
31import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution";
32
33import { 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.
38type 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).
42const 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.
45const 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.
48const BALANCE_CACHE_MAX_ENTRIES = 10_000;
49
50export { EXECUTION_LIMIT_BLOCKED_MESSAGE };
51
52export 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. */
58class 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
66export 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 */
78export 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
113export 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 */
123export 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
7export 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
10export 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
16import { DurableObject, env } from "cloudflare:workers";
17import * as Sentry from "@sentry/cloudflare";
18import { Data, Effect } from "effect";
19import type * as Cause from "effect/Cause";
20
21import type { ExecutionEngine } from "@executor-js/execution";
22
23import { withPreExecutionGate, type GateDecision } from "./execution-gate";
24import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages";
25
26// Fixed window: all executions in the same clock hour share one counter.
27export 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).
33export const EXECUTIONS_PER_ORG_PER_HOUR = 1000;
34// Counter DO slower than this => fail open rather than stall executions.
35const 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.
38const COUNTER_PURGE_AFTER_MS = 2 * RATE_LIMIT_WINDOW_MS;
39
40export { RATE_LIMIT_BLOCKED_MESSAGE };
41
42export 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. */
50class 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
61const WINDOW_RECORD_KEY = "window";
62
63type WindowRecord = {
64 readonly windowId: number;
65 readonly count: number;
66};
67
68export 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. */
97export type RateLimitIncrement = (
98 organizationId: string,
99 windowId: number,
100) => Effect.Effect<number, unknown>;
101
102export 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 */
115export 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.
173type ExecutionRateLimiterStub = {
174 readonly increment: (windowId: number) => Promise<number>;
175};
176
177type 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 */
187export 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 */
215const 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
28import { AutumnService } from "../extensions/billing/service";28import { AutumnService } from "../extensions/billing/service";
29import type { DbService } from "../db/db";29import type { DbService } from "../db/db";
30import { CloudExecutionSeamsLayer } from "../engine/execution-stack";30import { CloudExecutionSeamsLayer } from "../engine/execution-stack";
31import { makeExecutionLimitGate } from "./execution-gate";
32import { makeCloudExecutionRateLimiter } from "./execution-rate-limit";
31import { withExecutionUsageTracking } from "./execution-usage";33import { withExecutionUsageTracking } from "./execution-usage";
32 34
33// Usage-metering decorator bound to the billing service. `trackExecution` is35// Usage-metering decorator bound to the billing service, plus the two
34// fire-and-forget (`Effect.runFork`) so the billing call can't stall a36// pre-execution guards this layer owns, ordered cheapest first:
35// user-facing execution.37// 1. rate-limit backstop (counter DO, independent of billing)
38// 2. execution balance gate (Autumn check, cached 60s, fails open)
39// 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing
40// call can't stall a user-facing execution.
41// The guards wrap OUTSIDE the tracker so a blocked execution is neither run
42// nor tracked. One gate/limiter instance per layer build: the balance cache is
43// shared by every engine this decorator produces (in the MCP session DO that's
44// the session's engines; on the HTTP plane the boot layer builds it once).
36export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> =45export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, AutumnService> =
37 Layer.effect(EngineDecorator)(46 Layer.effect(EngineDecorator)(
38 Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => ({47 Effect.map(AutumnService.asEffect(), (autumn): EngineDecorator["Service"] => {
39 decorate: (engine, identity: EngineStackIdentity) =>48 const balanceGate = makeExecutionLimitGate((organizationId) =>
40 withExecutionUsageTracking(identity.organizationId, engine, (organizationId) =>49 autumn.checkExecutionBalance(organizationId),
41 Effect.runFork(autumn.trackExecution(organizationId)),50 );
42 ),51 const rateLimiter = makeCloudExecutionRateLimiter();
43 })),52 return {
53 decorate: (engine, identity: EngineStackIdentity) =>
54 rateLimiter.decorate(
55 identity.organizationId,
56 balanceGate.decorate(
57 identity.organizationId,
58 withExecutionUsageTracking(identity.organizationId, engine, (organizationId) =>
59 Effect.runFork(autumn.trackExecution(organizationId)),
60 ),
61 ),
62 ),
63 };
64 }),
44 );65 );
45 66
46/**67/**
apps/cloud/src/env-augment.d.ts+14−0
@@ -32,6 +32,20 @@ declare global { · 31 unmodified lines
32 // ON; the test workers set "true" so fixtures can reach localhost.32 // ON; the test workers set "true" so fixtures can reach localhost.
33 ALLOW_LOCAL_NETWORK?: string;33 ALLOW_LOCAL_NETWORK?: string;
34 34
35 // Per-org execution rate-limit counter DO (wrangler.jsonc
36 // `durable_objects`). Declared optional here (matching the BLOBS
37 // precedent) rather than regenerating worker-configuration.d.ts: test
38 // workers and older local setups run without the binding, and the
39 // limiter degrades to disabled when absent.
40 EXECUTION_RATE_LIMITER?: import("@cloudflare/workers-types").DurableObjectNamespace;
41
42 // Optional per-org hourly execution rate-limit override, parsed as an
43 // integer (defaults to EXECUTIONS_PER_ORG_PER_HOUR = 1000 when unset or
44 // unparseable). Exists for e2e: the production cap of 1000/hour can't be
45 // exercised with real executions, so the e2e dev-server env sets a small
46 // number to drive the backstop. Production leaves it unset.
47 EXECUTION_RATE_LIMIT_PER_HOUR?: string;
48
35 // Billing49 // Billing
36 AUTUMN_SECRET_KEY?: string;50 AUTUMN_SECRET_KEY?: string;
37 /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */51 /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */
apps/cloud/src/extensions/billing/service.ts+14−1
@@ -22,6 +22,9 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ · 21 unmodified lines
22 22
23export type IAutumnService = Readonly<{23export type IAutumnService = Readonly<{
24 use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>;24 use: <A>(fn: (client: Autumn) => Promise<A>) => Effect.Effect<A, AutumnError, never>;
25 checkExecutionBalance: (
26 organizationId: string,
27 ) => Effect.Effect<{ readonly allowed: boolean }, AutumnError, never>;
25 /**28 /**
26 * Fire-and-forget-safe execution usage tracker. Errors are caught and29 * Fire-and-forget-safe execution usage tracker. Errors are caught and
27 * logged; the returned Effect never fails. Callers typically30 * logged; the returned Effect never fails. Callers typically
@@ -44,6 +47,7 @@ const make = Effect.sync(() => { · 16 unmodified lines
44 );47 );
45 return {48 return {
46 use: () => notConfigured,49 use: () => notConfigured,
50 checkExecutionBalance: () => notConfigured,
47 trackExecution: () => Effect.void,51 trackExecution: () => Effect.void,
48 } satisfies IAutumnService;52 } satisfies IAutumnService;
49 }53 }
@@ -80,7 +84,16 @@ const make = Effect.sync(() => { · 30 unmodified lines
80 );84 );
81 }).pipe(Effect.withSpan("autumn.trackExecution"));85 }).pipe(Effect.withSpan("autumn.trackExecution"));
82 86
83 return { use, trackExecution } satisfies IAutumnService;87 const checkExecutionBalance = (organizationId: string) =>
88 Effect.gen(function* () {
89 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
90 const check = yield* use((c) =>
91 c.check({ customerId: organizationId, featureId: "executions" }),
92 );
93 return { allowed: check.allowed };
94 }).pipe(Effect.withSpan("autumn.checkExecutionBalance"));
95
96 return { use, checkExecutionBalance, trackExecution } satisfies IAutumnService;
84});97});
85 98
86export class AutumnService extends Context.Service<AutumnService, IAutumnService>()(99export 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
55// (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it.55// (with a `deleted_classes: ["McpSessionDO"]` migration) now that nothing binds it.
56export class McpSessionDO extends DurableObject {}56export class McpSessionDO extends DurableObject {}
57 57
58// Per-org execution rate-limit counter DO (abuse backstop; migration v3,
59// `EXECUTION_RATE_LIMITER` binding). Plain counter, no Sentry wrapper needed:
60// its callers already fail open and report errors themselves.
61export { ExecutionRateLimiterDO } from "./engine/execution-rate-limit";
62
58// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
59// Worker fetch handler64// Worker fetch handler
60//65//
apps/cloud/src/web/components/support-options.tsx+1−1
@@ -13,7 +13,7 @@ const supportLinks = [ · 12 unmodified lines
13 { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark },13 { label: "Discord", href: "https://discord.gg/eF29HBHwM6", icon: DiscordMark },
14 {14 {
15 label: "GitHub Issues",15 label: "GitHub Issues",
16 href: "https://github.com/RhysSullivan/executor/issues",16 href: "https://github.com/UsefulSoftwareCo/executor/issues",
17 icon: GitHubMark,17 icon: GitHubMark,
18 },18 },
19 { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark },19 { label: "Email", href: "mailto:rhys@executor.sh?subject=Executor%20support", icon: MailMark },
apps/cloud/wrangler.jsonc+11−0
@@ -22,6 +22,13 @@ · 21 unmodified lines
22 "name": "MCP_SESSION",22 "name": "MCP_SESSION",
23 "class_name": "McpSessionDOSqlite",23 "class_name": "McpSessionDOSqlite",
24 },24 },
25 // Per-org execution rate-limit counter (abuse backstop). One instance
26 // per organization via idFromName(orgId); a single { windowId, count }
27 // record per instance, purged by alarm after inactivity.
28 {
29 "name": "EXECUTION_RATE_LIMITER",
30 "class_name": "ExecutionRateLimiterDO",
31 },
25 ],32 ],
26 },33 },
27 // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which34 // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which
@@ -42,6 +49,10 @@ · 14 unmodified lines
42 "tag": "v2",49 "tag": "v2",
43 "new_sqlite_classes": ["McpSessionDOSqlite"],50 "new_sqlite_classes": ["McpSessionDOSqlite"],
44 },51 },
52 {
53 "tag": "v3",
54 "new_sqlite_classes": ["ExecutionRateLimiterDO"],
55 },
45 ],56 ],
46 "services": [57 "services": [
47 {58 {
apps/desktop/electron-builder.config.ts+1−1
@@ -57,7 +57,7 @@ const config: Configuration = { · 56 unmodified lines
57 },57 },
58 publish: {58 publish: {
59 provider: "github",59 provider: "github",
60 owner: "RhysSullivan",60 owner: "UsefulSoftwareCo",
61 repo: "executor",61 repo: "executor",
62 },62 },
63};63};
apps/desktop/electron.vite.config.ts+3−1
@@ -53,7 +53,9 @@ export default defineConfig({ · 52 unmodified lines
53 "import.meta.env.VITE_APP_VERSION": JSON.stringify(53 "import.meta.env.VITE_APP_VERSION": JSON.stringify(
54 process.env.npm_package_version ?? "0.0.0",54 process.env.npm_package_version ?? "0.0.0",
55 ),55 ),
56 "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"),56 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(
57 "https://github.com/UsefulSoftwareCo/executor",
58 ),
57 "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify(59 "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify(
58 process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh",60 process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh",
59 ),61 ),
apps/desktop/package.json+1−1
@@ -2,7 +2,7 @@ · 1 unmodified line
2 "name": "@executor-js/desktop",2 "name": "@executor-js/desktop",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "private": true,4 "private": true,
5 "homepage": "https://github.com/RhysSullivan/executor",5 "homepage": "https://github.com/UsefulSoftwareCo/executor",
6 "license": "MIT",6 "license": "MIT",
7 "type": "module",7 "type": "module",
8 "main": "./out/main/index.js",8 "main": "./out/main/index.js",
apps/desktop/src/main/diagnostics.ts+1−1
@@ -259,7 +259,7 @@ export const reportAProblem = async () => { · 258 unmodified lines
259 "",259 "",
260 "_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._",260 "_A diagnostics zip was saved to your Downloads folder — please drag it into this issue._",
261 ].join("\n");261 ].join("\n");
262 const url = new URL("https://github.com/RhysSullivan/executor/issues/new");262 const url = new URL("https://github.com/UsefulSoftwareCo/executor/issues/new");
263 url.searchParams.set("title", "[desktop] ");263 url.searchParams.set("title", "[desktop] ");
264 url.searchParams.set("body", body);264 url.searchParams.set("body", body);
265 await shell.openExternal(url.toString());265 await shell.openExternal(url.toString());
apps/docs/docs.json+2−2
@@ -45,7 +45,7 @@ · 44 unmodified lines
45 "links": [45 "links": [
46 {46 {
47 "label": "GitHub",47 "label": "GitHub",
48 "href": "https://github.com/RhysSullivan/executor"48 "href": "https://github.com/UsefulSoftwareCo/executor"
49 }49 }
50 ],50 ],
51 "primary": {51 "primary": {
@@ -56,7 +56,7 @@ · 4 unmodified lines
56 },56 },
57 "footer": {57 "footer": {
58 "socials": {58 "socials": {
59 "github": "https://github.com/RhysSullivan/executor"59 "github": "https://github.com/UsefulSoftwareCo/executor"
60 }60 }
61 }61 }
62}62}
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
15## Prerequisites15## Prerequisites
16 16
17- A Cloudflare account with Workers and Zero Trust (Access) enabled.17- A Cloudflare account with Workers and Zero Trust (Access) enabled.
18- The [Executor repo](https://github.com/RhysSullivan/executor) checked out, with18- The [Executor repo](https://github.com/UsefulSoftwareCo/executor) checked out, with
19 dependencies installed (`bun install`).19 dependencies installed (`bun install`).
20 20
21## Deploy21## Deploy
apps/docs/hosted/docker.mdx+3−3
@@ -17,10 +17,10 @@ docker run -d \ · 16 unmodified lines
17 --name executor-selfhost \17 --name executor-selfhost \
18 -p 4788:4788 \18 -p 4788:4788 \
19 -v executor-data:/data \19 -v executor-data:/data \
20 ghcr.io/rhyssullivan/executor-selfhost:latest20 ghcr.io/usefulsoftwareco/executor-selfhost:latest
21```21```
22 22
23Or build from a clone of the [repo](https://github.com/RhysSullivan/executor),23Or build from a clone of the [repo](https://github.com/UsefulSoftwareCo/executor),
24from `apps/host-selfhost`:24from `apps/host-selfhost`:
25 25
26```bash26```bash
@@ -81,7 +81,7 @@ docker run -d \ · 54 unmodified lines
81 -p 4788:4788 \81 -p 4788:4788 \
82 -v executor-data:/data \82 -v executor-data:/data \
83 -e EXECUTOR_WEB_BASE_URL=https://executor.example.com \83 -e EXECUTOR_WEB_BASE_URL=https://executor.example.com \
84 ghcr.io/rhyssullivan/executor-selfhost:latest84 ghcr.io/usefulsoftwareco/executor-selfhost:latest
85```85```
86 86
87For a headless deploy (CI or infra-as-code), set `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL`87For 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
543. Once the tools are available, help me add my first integration and get one tool working end to end.543. Once the tools are available, help me add my first integration and get one tool working end to end.
55 55
56Docs: https://executor.sh/docs56Docs: https://executor.sh/docs
57Source (and the place to start if something breaks): https://github.com/RhysSullivan/executor57Source (and the place to start if something breaks): https://github.com/UsefulSoftwareCo/executor
58```58```
59 59
60## Concepts60## Concepts
apps/host-cloudflare/vite.config.ts+3−1
@@ -50,7 +50,9 @@ export default defineConfig({ · 49 unmodified lines
50 // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm,50 // Cloudflare upgrades by redeploying the Worker (wrangler deploy), not npm,
51 // so the update card links to the upgrade guide instead of a command.51 // so the update card links to the upgrade guide instead of a command.
52 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"),52 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("cloudflare"),
53 "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"),53 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(
54 "https://github.com/UsefulSoftwareCo/executor",
55 ),
54 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"),56 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "production"),
55 },57 },
56 server: {58 server: {
apps/host-selfhost/Dockerfile+1−1
@@ -23,7 +23,7 @@ RUN cd apps/host-selfhost && bun run build · 22 unmodified lines
23 23
24FROM gcr.io/distroless/cc-debian12 AS runtime24FROM gcr.io/distroless/cc-debian12 AS runtime
25WORKDIR /app25WORKDIR /app
26LABEL org.opencontainers.image.source="https://github.com/RhysSullivan/executor" \26LABEL org.opencontainers.image.source="https://github.com/UsefulSoftwareCo/executor" \
27 org.opencontainers.image.description="Single-container self-hosted Executor" \27 org.opencontainers.image.description="Single-container self-hosted Executor" \
28 org.opencontainers.image.licenses="MIT"28 org.opencontainers.image.licenses="MIT"
29ENV NODE_ENV=production \29ENV NODE_ENV=production \
apps/host-selfhost/README.md+1−1
@@ -14,7 +14,7 @@ docker run -d \ · 13 unmodified lines
14 --name executor-selfhost \14 --name executor-selfhost \
15 -p 4788:4788 \15 -p 4788:4788 \
16 -v executor-data:/data \16 -v executor-data:/data \
17 ghcr.io/rhyssullivan/executor-selfhost:latest17 ghcr.io/usefulsoftwareco/executor-selfhost:latest
18```18```
19 19
20Or build from a repository clone:20Or build from a repository clone:
apps/host-selfhost/vite.config.ts+3−1
@@ -218,7 +218,9 @@ export default defineConfig({ · 217 unmodified lines
218 // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not218 // Self-host upgrades by pulling/rebuilding the image (or git + rebuild), not
219 // npm, so the update card links to the upgrade guide instead of a command.219 // npm, so the update card links to the upgrade guide instead of a command.
220 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"),220 "import.meta.env.VITE_UPGRADE_HINT": JSON.stringify("selfhost"),
221 "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"),221 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(
222 "https://github.com/UsefulSoftwareCo/executor",
223 ),
222 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),224 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
223 },225 },
224 server: {226 server: {
apps/local/drizzle/0003_graceful_the_fallen.sql+1−0
@@ -0,0 +1 @@
1ALTER 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
22 "when": 1782150869757,22 "when": 1782150869757,
23 "tag": "0002_empty_rictor",23 "tag": "0002_empty_rictor",
24 "breakpoints": true24 "breakpoints": true
25 },
26 {
27 "idx": 3,
28 "version": "6",
29 "when": 1783025153772,
30 "tag": "0003_graceful_the_fallen",
31 "breakpoints": true
25 }32 }
26 ]33 ]
27}34}
apps/local/src/db/data-migrations.ts+9−1
@@ -5,7 +5,12 @@ · 4 unmodified lines
5// renamed.5// renamed.
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8import { Effect, sqliteDataMigration, type SqliteDataMigration } from "@executor-js/sdk";8import {
9 Effect,
10 oauthClientGcSqliteMigration,
11 sqliteDataMigration,
12 type SqliteDataMigration,
13} from "@executor-js/sdk";
9import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";14import { runSqliteAuthConfigMigration } from "@executor-js/sdk/http-auth";
10import {15import {
11 openApiOutputSchemaDataMigration,16 openApiOutputSchemaDataMigration,
@@ -37,4 +42,7 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [ · 25 unmodified lines
37 openApiSpecBlobDataMigration,42 openApiSpecBlobDataMigration,
38 graphqlIntrospectionBlobDataMigration,43 graphqlIntrospectionBlobDataMigration,
39 googleOpenApiOwnershipDataMigration,44 googleOpenApiOwnershipDataMigration,
45 // GC dead DCR oauth_client rows (old always-register duplicates) and backfill
46 // the surviving DCR rows' origin_issuer from token_url (issue #1120, Part C).
47 oauthClientGcSqliteMigration,
40];48];
apps/local/src/db/executor-schema.ts+1−0
@@ -75,6 +75,7 @@ export const oauth_client = sqliteTable( · 74 unmodified lines
75 resource: text("resource"),75 resource: text("resource"),
76 origin_kind: text("origin_kind"),76 origin_kind: text("origin_kind"),
77 origin_integration: text("origin_integration"),77 origin_integration: text("origin_integration"),
78 origin_issuer: text("origin_issuer"),
78 created_at: integer("created_at").notNull(),79 created_at: integer("created_at").notNull(),
79 row_id: text("row_id").primaryKey().notNull(),80 row_id: text("row_id").primaryKey().notNull(),
80 tenant: text("tenant").notNull(),81 tenant: text("tenant").notNull(),
apps/local/src/db/sqlite-fumadb.ts+6−0
@@ -93,6 +93,12 @@ export const createSqliteFumaDb = async <const TTables extends FumaTables>( · 92 unmodified lines
93 ) {93 ) {
94 await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_integration TEXT");94 await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_integration TEXT");
95 }95 }
96 if (
97 oauthClientColumns.rows.length > 0 &&
98 !oauthClientColumns.rows.some((column) => column["name"] === "origin_issuer")
99 ) {
100 await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_issuer TEXT");
101 }
96 102
97 const { db, fuma } = createExecutorFumaDb(drizzleDb, {103 const { db, fuma } = createExecutorFumaDb(drizzleDb, {
98 tables: options.tables,104 tables: options.tables,
apps/local/vite.config.ts+1−1
@@ -34,7 +34,7 @@ const EXECUTOR_VERSION = cliPackage.version ?? rootPackage.version; · 33 unmodified lines
34const EXECUTOR_GITHUB_URL = (34const EXECUTOR_GITHUB_URL = (
35 rootPackage.homepage ??35 rootPackage.homepage ??
36 repositoryUrl ??36 repositoryUrl ??
37 "https://github.com/RhysSullivan/executor"37 "https://github.com/UsefulSoftwareCo/executor"
38)38)
39 .replace(/^git\+/, "")39 .replace(/^git\+/, "")
40 .replace(/\.git$/, "");40 .replace(/\.git$/, "");
apps/marketing/src/components/LegalLayout.astro+1−1
@@ -56,7 +56,7 @@ const { title, description, lastUpdated } = Astro.props; · 55 unmodified lines
56 <a href="/privacy" class="text-ink-faint transition-colors hover:text-ink">Privacy</a>56 <a href="/privacy" class="text-ink-faint transition-colors hover:text-ink">Privacy</a>
57 <a href="/terms" class="text-ink-faint transition-colors hover:text-ink">Terms</a>57 <a href="/terms" class="text-ink-faint transition-colors hover:text-ink">Terms</a>
58 <a58 <a
59 href="https://github.com/RhysSullivan/executor"59 href="https://github.com/UsefulSoftwareCo/executor"
60 class="text-ink-faint transition-colors hover:text-ink"60 class="text-ink-faint transition-colors hover:text-ink"
61 >61 >
62 GitHub62 GitHub
apps/marketing/src/layouts/BlogPostLayout.astro+1−1
@@ -21,7 +21,7 @@ const { · 20 unmodified lines
21 authorAvatar,21 authorAvatar,
22} = Astro.props;22} = Astro.props;
23 23
24const GH = "https://github.com/RhysSullivan/executor";24const GH = "https://github.com/UsefulSoftwareCo/executor";
25const authorX = `https://x.com/${authorHandle}`;25const authorX = `https://x.com/${authorHandle}`;
26 26
27const formattedDate = date.toLocaleDateString("en-US", {27const formattedDate = date.toLocaleDateString("en-US", {
apps/marketing/src/lib/github.ts+1−1
@@ -11,7 +11,7 @@ · 10 unmodified lines
11// - resolve to null on any failure, in which case the nav simply drops the11// - resolve to null on any failure, in which case the nav simply drops the
12// count and keeps the link.12// count and keeps the link.
13 13
14const REPO = "RhysSullivan/executor";14const REPO = "UsefulSoftwareCo/executor";
15 15
16export const GITHUB_REPO_URL = `https://github.com/${REPO}`;16export const GITHUB_REPO_URL = `https://github.com/${REPO}`;
17 17
apps/marketing/src/pages/blog/index.astro+1−1
@@ -3,7 +3,7 @@ export const prerender = true; · 2 unmodified lines
3import Layout from "../../layouts/Layout.astro";3import Layout from "../../layouts/Layout.astro";
4import { getCollection } from "astro:content";4import { getCollection } from "astro:content";
5 5
6const GH = "https://github.com/RhysSullivan/executor";6const GH = "https://github.com/UsefulSoftwareCo/executor";
7 7
8const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(8const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort(
9 (a, b) => b.data.date.getTime() - a.data.date.getTime(),9 (a, b) => b.data.date.getTime() - a.data.date.getTime(),
apps/marketing/src/pages/index.astro+2−2
@@ -8,7 +8,7 @@ import { SelfHostContactModal } from "../components/self-host-contact-modal"; · 7 unmodified lines
8import ycBackedBy from "../assets/yc-backed-by.svg?url";8import ycBackedBy from "../assets/yc-backed-by.svg?url";
9import { getStars, formatStars } from "../lib/github";9import { getStars, formatStars } from "../lib/github";
10 10
11const GH = "https://github.com/RhysSullivan/executor";11const GH = "https://github.com/UsefulSoftwareCo/executor";
12// Cloud sign-in. Same origin in prod: the cloud Worker owns executor.sh and12// Cloud sign-in. Same origin in prod: the cloud Worker owns executor.sh and
13// /api/* is app-owned (apps/cloud/src/app-paths.ts), so this hits the WorkOS13// /api/* is app-owned (apps/cloud/src/app-paths.ts), so this hits the WorkOS
14// login flow directly. Returning users land here; new users use Get started.14// login flow directly. Returning users land here; new users use Get started.
@@ -50,7 +50,7 @@ Once you know which form I want: · 35 unmodified lines
503. Once the tools are available, help me add my first integration and get one tool working end to end.503. Once the tools are available, help me add my first integration and get one tool working end to end.
51 51
52Docs: https://executor.sh/docs52Docs: https://executor.sh/docs
53Source (and the place to start if something breaks): https://github.com/RhysSullivan/executor`;53Source (and the place to start if something breaks): https://github.com/UsefulSoftwareCo/executor`;
54---54---
55 55
56<Layout>56<Layout>
bun.lock+4−2
@@ -344,7 +344,7 @@ · 343 unmodified lines
344 "version": "0.0.24",344 "version": "0.0.24",
345 "dependencies": {345 "dependencies": {
346 "@executor-js/api": "workspace:*",346 "@executor-js/api": "workspace:*",
347 "@executor-js/emulate": "^0.10.0",347 "@executor-js/emulate": "^0.13.2",
348 "@executor-js/mcporter": "^0.11.4",348 "@executor-js/mcporter": "^0.11.4",
349 "@executor-js/plugin-google": "workspace:*",349 "@executor-js/plugin-google": "workspace:*",
350 "@executor-js/plugin-graphql": "workspace:*",350 "@executor-js/plugin-graphql": "workspace:*",
@@ -583,6 +583,7 @@ · 232 unmodified lines
583 "@standard-schema/spec": "^1.1.0",583 "@standard-schema/spec": "^1.1.0",
584 "fractional-indexing": "^3.2.0",584 "fractional-indexing": "^3.2.0",
585 "oauth4webapi": "^3.8.5",585 "oauth4webapi": "^3.8.5",
586 "tldts": "^7.0.28",
586 },587 },
587 "devDependencies": {588 "devDependencies": {
588 "@effect/atom-react": "catalog:",589 "@effect/atom-react": "catalog:",
@@ -665,6 +666,7 @@ · 76 unmodified lines
665 "@types/node": "catalog:",666 "@types/node": "catalog:",
666 "bun-types": "catalog:",667 "bun-types": "catalog:",
667 "typescript": "catalog:",668 "typescript": "catalog:",
669 "urlpattern-polyfill": "^10.1.0",
668 "vitest": "catalog:",670 "vitest": "catalog:",
669 },671 },
670 },672 },
@@ -1743,7 +1745,7 @@ · 1072 unmodified lines
1743 1745
1744 "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"],1746 "@executor-js/e2e": ["@executor-js/e2e@workspace:e2e"],
1745 1747
1746 "@executor-js/emulate": ["@executor-js/emulate@0.10.0", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-GE1+XDQ4FJt4ZDrwNjuUqUEG1WaH06UE12ME/xJdcCNbsa6EE6SA+i8onVJQ5Dr7DbUveU37E4djLTiAcceLPw=="],1748 "@executor-js/emulate": ["@executor-js/emulate@0.13.2", "", { "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/client-sqs": "^3.1075.0", "@azure/msal-node": "^5.3.0", "@clerk/backend": "^3.8.4", "@octokit/rest": "^22.0.1", "@okta/okta-auth-js": "^8.0.1", "@slack/web-api": "^7.16.0", "@vercel/sdk": "^1.28.4", "@workos-inc/node": "^8.13.0", "atlas-api-client": "^0.3.0", "autumn-js": "^1.2.8", "commander": "^14", "googleapis": "^173.0.0", "graphql": "^16.9.0", "graphql-request": "^7.4.0", "openid-client": "^6.8.4", "picocolors": "^1.1.1", "resend": "^6.16.0", "spotify-web-api-node": "^5.0.2", "stripe": "^22.3.0", "twitter-api-v2": "^1.29.0", "yaml": "^2" }, "bin": { "emulate": "dist/index.js" } }, "sha512-ikmzZgBrRnmJgKKOW9TE2KCCdenKtihx2nWW/mMMmugn3UlVJsxuIxp6jv0BEo4GZcY8Ve2oka6UEKl+gBKRZg=="],
1747 1749
1748 "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"],1750 "@executor-js/example-all-plugins": ["@executor-js/example-all-plugins@workspace:examples/all-plugins"],
1749 1751
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.
31import { randomBytes } from "node:crypto";
32
33import { expect } from "@effect/vitest";
34import { Effect } from "effect";
35import { composePluginApi } from "@executor-js/api/server";
36import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
37import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
38import { serveOAuthTestServer } from "@executor-js/sdk/testing";
39
40import { scenario } from "../src/scenario";
41import { Api, Target } from "../src/services";
42
43const api = composePluginApi([openApiHttpPlugin()] as const);
44
45const 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.
50const MCP_ISSUER = "https://mcp.cloudflare.com";
51const MCP_AUTHORIZE = "https://mcp.cloudflare.com/authorize";
52const MCP_TOKEN = "https://mcp.cloudflare.com/token";
53const REST_AUTHORIZE = "https://api.cloudflare.com/client/v4/oauth/authorize";
54const REST_TOKEN = "https://api.cloudflare.com/client/v4/oauth/token";
55
56scenario(
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.
16import { expect } from "@effect/vitest";
17import { Effect } from "effect";
18
19import {
20 EXECUTION_LIMIT_BLOCKED_MESSAGE,
21 RATE_LIMIT_BLOCKED_MESSAGE,
22} from "../../apps/cloud/src/engine/execution-limit-messages";
23import { scenario } from "../src/scenario";
24import { Autumn, Billing, Mcp, Target } from "../src/services";
25import { E2E_EXECUTION_RATE_LIMIT } from "../setup/execution-limits";
26import type { Identity } from "../src/target";
27
28const 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. */
32const 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.
44const RATE_LIMIT = E2E_EXECUTION_RATE_LIMIT;
45
46scenario(
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
88scenario(
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
125scenario(
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
159scenario(
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
22 },22 },
23 "dependencies": {23 "dependencies": {
24 "@executor-js/api": "workspace:*",24 "@executor-js/api": "workspace:*",
25 "@executor-js/emulate": "^0.10.0",25 "@executor-js/emulate": "^0.13.2",
26 "@executor-js/mcporter": "^0.11.4",26 "@executor-js/mcporter": "^0.11.4",
27 "@executor-js/plugin-google": "workspace:*",27 "@executor-js/plugin-google": "workspace:*",
28 "@executor-js/plugin-graphql": "workspace:*",28 "@executor-js/plugin-graphql": "workspace:*",
e2e/setup/autumn-plans.ts+18−0
@@ -49,3 +49,21 @@ const toSeed = (plan: AtmnPlan): AutumnSeedPlan => ({ · 48 unmodified lines
49export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) =>49export const AUTUMN_PLAN_SEED: AutumnSeedPlan[] = [free, team, enterprise].map((p) =>
50 toSeed(p as AtmnPlan),50 toSeed(p as AtmnPlan),
51);51);
52
53/**
54 * The "executions" allotment a fresh org has on the auto-enabled default plan
55 * (Free): the exact number of executions it takes to exhaust the quota. Read
56 * from the same seed the emulator is booted with, so a plan change in
57 * autumn.config.ts flows straight into the balance-gate scenarios rather than
58 * drifting against a hardcoded magic number.
59 */
60export 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
12 12
13import { bootProcesses, waitForHttp } from "./boot";13import { bootProcesses, waitForHttp } from "./boot";
14import { AUTUMN_PLAN_SEED } from "./autumn-plans";14import { AUTUMN_PLAN_SEED } from "./autumn-plans";
15import { E2E_EXECUTION_RATE_LIMIT } from "./execution-limits";
15 16
16export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));17export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));
17 18
@@ -90,6 +91,12 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 72 unmodified lines
90 MCP_AUTHKIT_DOMAIN: workosUrl,91 MCP_AUTHKIT_DOMAIN: workosUrl,
91 MCP_RESOURCE_ORIGIN: options.publicUrl,92 MCP_RESOURCE_ORIGIN: options.publicUrl,
92 ALLOW_LOCAL_NETWORK: "true",93 ALLOW_LOCAL_NETWORK: "true",
94 // Shrink the per-org hourly execution cap (prod default 1000) to a number
95 // the rate-limit-backstop scenario can actually exhaust with real
96 // executions — but see execution-limits.ts: it must stay above every other
97 // scenario's per-org execute count. Reaches the worker via
98 // CLOUDFLARE_INCLUDE_PROCESS_ENV, same as ALLOW_LOCAL_NETWORK.
99 EXECUTION_RATE_LIMIT_PER_HOUR: String(E2E_EXECUTION_RATE_LIMIT),
93 // Throwaway PGlite on its own port + dir so it never fights `bun dev`.100 // Throwaway PGlite on its own port + dir so it never fights `bun dev`.
94 DEV_DB_PORT: String(options.dbPort),101 DEV_DB_PORT: String(options.dbPort),
95 DEV_DB_PATH: dbPath,102 DEV_DB_PATH: dbPath,
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.
14export const E2E_EXECUTION_RATE_LIMIT = 20;
e2e/src/surfaces/autumn.ts+123−0
@@ -9,6 +9,8 @@ · 8 unmodified lines
9// so the contract has to be pinned where the usage is read.9// so the contract has to be pinned where the usage is read.
10import { Effect, Schedule } from "effect";10import { Effect, Schedule } from "effect";
11 11
12import { DEFAULT_PLAN_EXECUTIONS_INCLUDED } from "../../setup/autumn-plans";
13
12/** One usage event Autumn recorded: a `track` of `value` units of `featureId`14/** One usage event Autumn recorded: a `track` of `value` units of `featureId`
13 * for `customerId` (the organization the execution ran under). */15 * for `customerId` (the organization the execution ran under). */
14export interface UsageEvent {16export interface UsageEvent {
@@ -24,6 +26,33 @@ export interface UsageQuery { · 9 unmodified lines
24 readonly featureId: string;26 readonly featureId: string;
25}27}
26 28
29/** A one-shot (or counted) fault to arm against a matching Autumn request. Mirrors
30 * the emulator's `POST /_emulate/faults` body: a request matching every provided
31 * `match` criterion short-circuits with `response`, `times` decrements per hit
32 * (default 1, removed at zero), and `delayMs` stalls before the fault returns —
33 * the knob a timeout scenario uses to push Autumn past the gate's time budget. */
34export 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. */
49export interface LedgerEntry {
50 readonly operationId?: string;
51 readonly method: string;
52 readonly path: string;
53 readonly faulted: boolean;
54}
55
27export interface AutumnSurface {56export interface AutumnSurface {
28 /** One-shot read of the matching usage events from the ledger. */57 /** One-shot read of the matching usage events from the ledger. */
29 readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>;58 readonly usageEvents: (query: UsageQuery) => Effect.Effect<readonly UsageEvent[], unknown>;
@@ -41,6 +70,20 @@ export interface AutumnSurface { · 11 unmodified lines
41 * the billing backend becomes consistent. The `sessionId` is the last path70 * the billing backend becomes consistent. The `sessionId` is the last path
42 * segment of the hosted checkout URL the browser was sent to. */71 * segment of the hosted checkout URL the browser was sent to. */
43 readonly settleCheckout: (sessionId: string) => Effect.Effect<void, unknown>;72 readonly settleCheckout: (sessionId: string) => Effect.Effect<void, unknown>;
73 /** Burn an org's entire remaining "executions" balance in one `balances.track`,
74 * so the next `balances.check` reports `allowed: false`. The amount is the
75 * default plan's included allotment (read from the plan seed, never hardcoded),
76 * which drives `remaining` to zero. This is the setup a "blocked at cap"
77 * scenario runs before opening the session it expects to be gated. */
78 readonly exhaustExecutions: (customerId: string) => Effect.Effect<void, unknown>;
79 /** Arm a fault against a matching Autumn request (`POST /_emulate/faults`). */
80 readonly armFault: (input: FaultInput) => Effect.Effect<void, unknown>;
81 /** Clear every armed fault (`DELETE /_emulate/faults`) — the finalizer that
82 * keeps a scenario's fault from leaking into the next one. */
83 readonly clearFaults: () => Effect.Effect<void, unknown>;
84 /** Read the emulator's request ledger, filtered to one operation — used to
85 * prove a faulted `balances.check` was actually attempted (fail-open proof). */
86 readonly ledgerFor: (operationId: string) => Effect.Effect<readonly LedgerEntry[], unknown>;
44}87}
45 88
46export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => {89export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => {
@@ -90,9 +133,89 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { · 43 unmodified lines
90 }133 }
91 });134 });
92 135
136 // Track exactly the plan's included allotment in one event, driving
137 // `remaining` (included - usage) to zero so the next check reports blocked.
138 const exhaustExecutions = (customerId: string) =>
139 Effect.gen(function* () {
140 const response = yield* Effect.promise(() =>
141 fetch(`${autumnUrl}/v1/balances.track`, {
142 method: "POST",
143 headers: { "content-type": "application/json" },
144 body: JSON.stringify({
145 customer_id: customerId,
146 feature_id: "executions",
147 value: DEFAULT_PLAN_EXECUTIONS_INCLUDED,
148 }),
149 }),
150 );
151 if (!response.ok) {
152 return yield* Effect.fail(
153 `autumn balances.track responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,
154 );
155 }
156 });
157
158 const armFault = (input: FaultInput) =>
159 Effect.gen(function* () {
160 const response = yield* Effect.promise(() =>
161 fetch(`${autumnUrl}/_emulate/faults`, {
162 method: "POST",
163 headers: { "content-type": "application/json" },
164 body: JSON.stringify(input),
165 }),
166 );
167 if (!response.ok) {
168 return yield* Effect.fail(
169 `autumn arm fault responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,
170 );
171 }
172 });
173
174 const clearFaults = () =>
175 Effect.gen(function* () {
176 const response = yield* Effect.promise(() =>
177 fetch(`${autumnUrl}/_emulate/faults`, { method: "DELETE" }),
178 );
179 if (!response.ok) {
180 return yield* Effect.fail(
181 `autumn clear faults responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,
182 );
183 }
184 });
185
186 const ledgerFor = (operationId: string) =>
187 Effect.gen(function* () {
188 const response = yield* Effect.promise(() => fetch(`${autumnUrl}/_emulate/ledger`));
189 if (!response.ok) {
190 return yield* Effect.fail(
191 `autumn ledger responded ${response.status}: ${yield* Effect.promise(() => response.text())}`,
192 );
193 }
194 const body = (yield* Effect.promise(() => response.json())) as {
195 readonly entries?: ReadonlyArray<{
196 readonly operationId?: string;
197 readonly method?: string;
198 readonly path?: string;
199 readonly faulted?: boolean;
200 }>;
201 };
202 return (body.entries ?? [])
203 .filter((entry) => entry.operationId === operationId)
204 .map((entry) => ({
205 operationId: entry.operationId,
206 method: entry.method ?? "",
207 path: entry.path ?? "",
208 faulted: entry.faulted === true,
209 }));
210 });
211
93 return {212 return {
94 usageEvents,213 usageEvents,
95 settleCheckout,214 settleCheckout,
215 exhaustExecutions,
216 armFault,
217 clearFaults,
218 ledgerFor,
96 expectUsage: (query) =>219 expectUsage: (query) =>
97 usageEvents(query).pipe(220 usageEvents(query).pipe(
98 Effect.filterOrFail(221 Effect.filterOrFail(
package.json+3−3
@@ -11,14 +11,14 @@ · 10 unmodified lines
11 "executor",11 "executor",
12 "local-first"12 "local-first"
13 ],13 ],
14 "homepage": "https://github.com/RhysSullivan/executor",14 "homepage": "https://github.com/UsefulSoftwareCo/executor",
15 "bugs": {15 "bugs": {
16 "url": "https://github.com/RhysSullivan/executor/issues"16 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
17 },17 },
18 "license": "MIT",18 "license": "MIT",
19 "repository": {19 "repository": {
20 "type": "git",20 "type": "git",
21 "url": "git+https://github.com/RhysSullivan/executor.git"21 "url": "git+https://github.com/UsefulSoftwareCo/executor.git"
22 },22 },
23 "workspaces": [23 "workspaces": [
24 "packages/*/*",24 "packages/*/*",
packages/app/vite.config.ts+3−1
@@ -13,7 +13,9 @@ export default defineConfig({ · 12 unmodified lines
13 plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })],13 plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })],
14 define: {14 define: {
15 "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-dev"),15 "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-dev"),
16 "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"),16 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(
17 "https://github.com/UsefulSoftwareCo/executor",
18 ),
17 "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),19 "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT),
18 },20 },
19});21});
packages/core/api/src/handlers/oauth.ts+2−0
@@ -103,6 +103,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 102 unmodified lines
103 clientId: payload.clientId,103 clientId: payload.clientId,
104 clientSecret: payload.clientSecret,104 clientSecret: payload.clientSecret,
105 resource: payload.resource ?? null,105 resource: payload.resource ?? null,
106 origin: { kind: "manual", integration: payload.originIntegration ?? null },
106 });107 });
107 return { client };108 return { client };
108 }),109 }),
@@ -115,6 +116,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler · 6 unmodified lines
115 const client = yield* executor.oauth.registerDynamicClient({116 const client = yield* executor.oauth.registerDynamicClient({
116 owner: payload.owner,117 owner: payload.owner,
117 slug: payload.slug,118 slug: payload.slug,
119 issuer: payload.issuer ?? null,
118 registrationEndpoint: payload.registrationEndpoint,120 registrationEndpoint: payload.registrationEndpoint,
119 authorizationUrl: payload.authorizationUrl,121 authorizationUrl: payload.authorizationUrl,
120 tokenUrl: payload.tokenUrl,122 tokenUrl: payload.tokenUrl,
packages/core/api/src/oauth/api.ts+5−0
@@ -65,6 +65,9 @@ const CreateClientPayload = Schema.Struct({ · 64 unmodified lines
65 clientId: Schema.String,65 clientId: Schema.String,
66 clientSecret: Schema.String,66 clientSecret: Schema.String,
67 resource: Schema.optional(Schema.NullOr(Schema.String)),67 resource: Schema.optional(Schema.NullOr(Schema.String)),
68 /** Integration whose connect dialog registered this manual app. Recorded so
69 * the picker matches it to this integration by intent, not root domain. */
70 originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)),
68});71});
69 72
70const CreateClientResponse = Schema.Struct({73const CreateClientResponse = Schema.Struct({
@@ -81,6 +84,7 @@ const CreateClientResponse = Schema.Struct({ · 10 unmodified lines
81const RegisterDynamicPayload = Schema.Struct({84const RegisterDynamicPayload = Schema.Struct({
82 owner: Owner,85 owner: Owner,
83 slug: OAuthClientSlug,86 slug: OAuthClientSlug,
87 issuer: Schema.optional(Schema.NullOr(Schema.String)),
84 registrationEndpoint: Schema.String,88 registrationEndpoint: Schema.String,
85 authorizationUrl: Schema.String,89 authorizationUrl: Schema.String,
86 tokenUrl: Schema.String,90 tokenUrl: Schema.String,
@@ -203,6 +207,7 @@ const ProbePayload = Schema.Struct({ · 116 unmodified lines
203});207});
204 208
205const ProbeResponse = Schema.Struct({209const ProbeResponse = Schema.Struct({
210 issuer: Schema.optional(Schema.NullOr(Schema.String)),
206 authorizationUrl: Schema.String,211 authorizationUrl: Schema.String,
207 tokenUrl: Schema.String,212 tokenUrl: Schema.String,
208 resource: Schema.optional(Schema.NullOr(Schema.String)),213 resource: Schema.optional(Schema.NullOr(Schema.String)),
packages/core/cli/package.json+3−3
@@ -2,14 +2,14 @@ · 1 unmodified line
2 "name": "@executor-js/cli",2 "name": "@executor-js/cli",
3 "version": "0.2.34",3 "version": "0.2.34",
4 "description": "CLI for the executor SDK — schema generation, migrations",4 "description": "CLI for the executor SDK — schema generation, migrations",
5 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/cli",5 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli",
6 "bugs": {6 "bugs": {
7 "url": "https://github.com/RhysSullivan/executor/issues"7 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
8 },8 },
9 "license": "MIT",9 "license": "MIT",
10 "repository": {10 "repository": {
11 "type": "git",11 "type": "git",
12 "url": "git+https://github.com/RhysSullivan/executor.git",12 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
13 "directory": "packages/core/cli"13 "directory": "packages/core/cli"
14 },14 },
15 "bin": {15 "bin": {
packages/core/config/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/config",2 "name": "@executor-js/config",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/config",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/core/config"12 "directory": "packages/core/config"
13 },13 },
14 "files": [14 "files": [
packages/core/execution/README.md+1−1
@@ -126,7 +126,7 @@ import { createExecutionEngine } from "@executor-js/execution/core"; · 125 unmodified lines
126 126
127## Status127## Status
128 128
129Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).129Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
130 130
131## License131## License
132 132
packages/core/execution/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/execution",2 "name": "@executor-js/execution",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/execution",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/core/execution"12 "directory": "packages/core/execution"
13 },13 },
14 "files": [14 "files": [
packages/core/fumadb/package.json+1−1
@@ -4,7 +4,7 @@ · 3 unmodified lines
4 "version": "1.5.7",4 "version": "1.5.7",
5 "repository": {5 "repository": {
6 "type": "git",6 "type": "git",
7 "url": "git+https://github.com/RhysSullivan/executor.git",7 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
8 "directory": "packages/core/fumadb"8 "directory": "packages/core/fumadb"
9 },9 },
10 "author": "Fuma Nama",10 "author": "Fuma Nama",
packages/core/integrations-registry/package.json+3−3
@@ -2,14 +2,14 @@ · 1 unmodified line
2 "name": "@executor-js/integrations-registry",2 "name": "@executor-js/integrations-registry",
3 "version": "0.1.0",3 "version": "0.1.0",
4 "private": true,4 "private": true,
5 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/integrations-registry",5 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/integrations-registry",
6 "bugs": {6 "bugs": {
7 "url": "https://github.com/RhysSullivan/executor/issues"7 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
8 },8 },
9 "license": "MIT",9 "license": "MIT",
10 "repository": {10 "repository": {
11 "type": "git",11 "type": "git",
12 "url": "git+https://github.com/RhysSullivan/executor.git",12 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
13 "directory": "packages/core/integrations-registry"13 "directory": "packages/core/integrations-registry"
14 },14 },
15 "files": [15 "files": [
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
126await executor.close();126await executor.close();
127```127```
128 128
129The 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.129The same pattern is what every shipped `@executor-js/plugin-*` package does internally. See [`packages/plugins/file-secrets`](https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets) for a production example that backs `secretProviders` with an XDG-located JSON file.
130 130
131## Plugins131## Plugins
132 132
@@ -165,7 +165,7 @@ Plugins that need a token — HTTP-backed sources, OAuth flows, etc. — accept · 32 unmodified lines
165 165
166## Status166## Status
167 167
168Pre-`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.168Pre-`1.0`. APIs may still change between beta releases. See the [executor monorepo](https://github.com/UsefulSoftwareCo/executor) for the current development branch and roadmap.
169 169
170## License170## License
171 171
packages/core/sdk/package.json+5−4
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/sdk",2 "name": "@executor-js/sdk",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/sdk",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/core/sdk"12 "directory": "packages/core/sdk"
13 },13 },
14 "files": [14 "files": [
@@ -96,7 +96,8 @@ · 81 unmodified lines
96 "@executor-js/fumadb": "workspace:*",96 "@executor-js/fumadb": "workspace:*",
97 "@standard-schema/spec": "^1.1.0",97 "@standard-schema/spec": "^1.1.0",
98 "fractional-indexing": "^3.2.0",98 "fractional-indexing": "^3.2.0",
99 "oauth4webapi": "^3.8.5"99 "oauth4webapi": "^3.8.5",
100 "tldts": "^7.0.28"
100 },101 },
101 "devDependencies": {102 "devDependencies": {
102 "@effect/atom-react": "catalog:",103 "@effect/atom-react": "catalog:",
packages/core/sdk/src/core-schema.ts+8−0
@@ -223,6 +223,14 @@ export const coreTables = defineTables({ · 222 unmodified lines
223 // "manual" by the service layer.223 // "manual" by the service layer.
224 origin_kind: nullableTextColumn("origin_kind"),224 origin_kind: nullableTextColumn("origin_kind"),
225 origin_integration: nullableTextColumn("origin_integration"),225 origin_integration: nullableTextColumn("origin_integration"),
226 // Authorization-server issuer that owns a DCR client, keying per-AS reuse.
227 // For a NEW DCR registration this is the DISCOVERED OIDC issuer (real
228 // information from the AS metadata, which can legitimately differ from what
229 // token_url would suggest). For a BACKFILLED legacy row it is instead a
230 // derived registrable-origin of token_url (a cache of the pure
231 // `registrableOriginOfUrl`, since no discovered issuer was ever recorded).
232 // Null for manual clients and legacy rows not yet backfilled.
233 origin_issuer: nullableTextColumn("origin_issuer"),
226 created_at: dateColumn("created_at"),234 created_at: dateColumn("created_at"),
227 },235 },
228 ["tenant", "owner", "subject", "slug"],236 ["tenant", "owner", "subject", "slug"],
packages/core/sdk/src/core-tools.ts+14−0
@@ -243,6 +243,9 @@ const OAuthCreateClientInput = Schema.Struct({ · 242 unmodified lines
243 grant: OAuthGrantSchema,243 grant: OAuthGrantSchema,
244 clientId: Schema.String,244 clientId: Schema.String,
245 resource: Schema.optional(Schema.NullOr(Schema.String)),245 resource: Schema.optional(Schema.NullOr(Schema.String)),
246 /** Integration whose connect dialog registered this manual app. Recorded so
247 * the picker can match it by intent (exact) instead of by root domain. */
248 originIntegration: Schema.optional(Schema.NullOr(Schema.String)),
246});249});
247// Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret250// Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret
248// fields the form pre-fills. The client secret is typed by the human in the web251// 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
269const OAuthRegisterDynamicInput = Schema.Struct({272const OAuthRegisterDynamicInput = Schema.Struct({
270 owner: OwnerSchema,273 owner: OwnerSchema,
271 slug: Schema.String,274 slug: Schema.String,
275 issuer: Schema.optional(Schema.NullOr(Schema.String)),
272 registrationEndpoint: Schema.String,276 registrationEndpoint: Schema.String,
273 authorizationUrl: Schema.String,277 authorizationUrl: Schema.String,
274 tokenUrl: Schema.String,278 tokenUrl: Schema.String,
@@ -287,6 +291,7 @@ const OAuthProbeInput = Schema.Struct({ · 12 unmodified lines
287 url: Schema.String,291 url: Schema.String,
288});292});
289const OAuthProbeOutput = Schema.Struct({293const OAuthProbeOutput = Schema.Struct({
294 issuer: Schema.optional(Schema.NullOr(Schema.String)),
290 authorizationUrl: Schema.String,295 authorizationUrl: Schema.String,
291 tokenUrl: Schema.String,296 tokenUrl: Schema.String,
292 resource: Schema.optional(Schema.NullOr(Schema.String)),297 resource: Schema.optional(Schema.NullOr(Schema.String)),
@@ -711,6 +716,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 418 unmodified lines
711 // `oauth.clients.createHandoff`.716 // `oauth.clients.createHandoff`.
712 clientSecret: "",717 clientSecret: "",
713 resource: input.resource ?? null,718 resource: input.resource ?? null,
719 origin: {
720 kind: "manual",
721 integration:
722 input.originIntegration == null
723 ? null
724 : IntegrationSlug.make(input.originIntegration),
725 },
714 }),726 }),
715 (client) => ({ client: String(client) }),727 (client) => ({ client: String(client) }),
716 ),728 ),
@@ -748,6 +760,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 31 unmodified lines
748 ctx.oauth.registerDynamicClient({760 ctx.oauth.registerDynamicClient({
749 owner: input.owner as Owner,761 owner: input.owner as Owner,
750 slug: OAuthClientSlug.make(input.slug),762 slug: OAuthClientSlug.make(input.slug),
763 issuer: input.issuer ?? null,
751 registrationEndpoint: input.registrationEndpoint,764 registrationEndpoint: input.registrationEndpoint,
752 authorizationUrl: input.authorizationUrl,765 authorizationUrl: input.authorizationUrl,
753 tokenUrl: input.tokenUrl,766 tokenUrl: input.tokenUrl,
@@ -789,6 +802,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 35 unmodified lines
789 outputSchema: OAuthProbeOutputStd,802 outputSchema: OAuthProbeOutputStd,
790 execute: (input: typeof OAuthProbeInput.Type, { ctx }) =>803 execute: (input: typeof OAuthProbeInput.Type, { ctx }) =>
791 Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({804 Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({
805 issuer: result.issuer ?? null,
792 authorizationUrl: result.authorizationUrl,806 authorizationUrl: result.authorizationUrl,
793 tokenUrl: result.tokenUrl,807 tokenUrl: result.tokenUrl,
794 resource: result.resource ?? null,808 resource: result.resource ?? null,
packages/core/sdk/src/index.ts+13−0
@@ -386,6 +386,19 @@ export { · 385 unmodified lines
386 runSqliteConfigBlobMigration,386 runSqliteConfigBlobMigration,
387 type SqliteConfigBlobMigrationOptions,387 type SqliteConfigBlobMigrationOptions,
388} from "./sqlite-config-blob-migration";388} from "./sqlite-config-blob-migration";
389// DCR oauth_client GC + issuer backfill: shared classification predicates and
390// the libSQL boot-migration body (issue #1120, Part C).
391export {
392 classifyOAuthClientGc,
393 isDcrClassifiedRow,
394 registrableOriginOfUrl,
395 type OAuthClientGcDecision,
396 type OAuthClientGcRow,
397} from "./oauth-gc";
398export {
399 oauthClientGcSqliteMigration,
400 runSqliteOAuthClientGcMigration,
401} from "./sqlite-oauth-client-gc-migration";
389export {402export {
390 authToolFailure,403 authToolFailure,
391 type AuthToolFailureCode,404 type AuthToolFailureCode,
packages/core/sdk/src/oauth-client.ts+14−1
@@ -64,7 +64,15 @@ export interface OAuthClient { · 63 unmodified lines
64}64}
65 65
66export type OAuthClientOrigin =66export type OAuthClientOrigin =
67 | { readonly kind: "manual" }67 | {
68 readonly kind: "manual";
69 /** Integration whose connect dialog registered this manual app, when the
70 * registration happened from within one. Lets the picker match a BYO app
71 * to its integration by recorded intent (exact) instead of guessing by
72 * root domain. Null for apps registered outside any integration context
73 * (and for legacy rows predating the stamp). */
74 readonly integration?: IntegrationSlug | null;
75 }
68 | {76 | {
69 readonly kind: "dynamic_client_registration";77 readonly kind: "dynamic_client_registration";
70 readonly integration?: IntegrationSlug | null;78 readonly integration?: IntegrationSlug | null;
@@ -72,6 +80,7 @@ export type OAuthClientOrigin = · 1 unmodified line
72 80
73export type CreateOAuthClientInput = OAuthClient & {81export type CreateOAuthClientInput = OAuthClient & {
74 readonly origin?: OAuthClientOrigin;82 readonly origin?: OAuthClientOrigin;
83 readonly originIssuer?: string | null;
75};84};
76 85
77/** Metadata-only projection of a registered client for listing in the UI.86/** Metadata-only projection of a registered client for listing in the UI.
@@ -134,6 +143,8 @@ export interface OAuthProbeInput { · 56 unmodified lines
134}143}
135 144
136export interface OAuthProbeResult {145export interface OAuthProbeResult {
146 /** RFC 8414 authorization-server issuer. Used to key DCR clients per AS. */
147 readonly issuer?: string | null;
137 readonly authorizationUrl: string;148 readonly authorizationUrl: string;
138 readonly tokenUrl: string;149 readonly tokenUrl: string;
139 /** RFC 8707 resource indicator discovered from protected-resource metadata.150 /** RFC 8707 resource indicator discovered from protected-resource metadata.
@@ -157,6 +168,8 @@ export interface OAuthProbeResult { · 17 unmodified lines
157export interface RegisterDynamicClientInput {168export interface RegisterDynamicClientInput {
158 readonly owner: Owner;169 readonly owner: Owner;
159 readonly slug: OAuthClientSlug;170 readonly slug: OAuthClientSlug;
171 /** RFC 8414 authorization-server issuer, when discovered before DCR. */
172 readonly issuer?: string | null;
160 /** RFC 7591 registration endpoint advertised by the authorization server. */173 /** RFC 7591 registration endpoint advertised by the authorization server. */
161 readonly registrationEndpoint: string;174 readonly registrationEndpoint: string;
162 readonly authorizationUrl: string;175 readonly authorizationUrl: string;
packages/core/sdk/src/oauth-gc.test.ts+221−0
@@ -0,0 +1,221 @@
1import { describe, expect, test } from "@effect/vitest";
2
3import {
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
15const explicitDcr: OAuthClientGcRow = {
16 grant: "authorization_code",
17 resource: "https://cloudflare.example/mcp",
18 origin_kind: "dynamic_client_registration",
19};
20
21const 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
28const manual: OAuthClientGcRow = {
29 grant: "authorization_code",
30 resource: null,
31 origin_kind: "manual",
32};
33
34describe("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
125describe("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
179describe("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
29import { getDomain } from "tldts";
30
31/** Parse a string into a URL, or null when it is not a valid absolute URL. */
32export 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. */
40export 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
50export 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. */
60export 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. */
71export 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. */
86export 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 */
121export 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. */
130export 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 */
149export 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
91 tokenUrl: "https://acme.test/token",91 tokenUrl: "https://acme.test/token",
92 resource: null,92 resource: null,
93 clientId: "org-client-id",93 clientId: "org-client-id",
94 origin: { kind: "manual" },94 // Manual apps carry a nullable recorded-intent integration; a client
95 // created outside any integration dialog stamps null.
96 origin: { kind: "manual", integration: null },
95 });97 });
96 expect(user!.owner).toBe("user");98 expect(user!.owner).toBe("user");
97 expect(user!.grant).toBe("client_credentials");99 expect(user!.grant).toBe("client_credentials");
packages/core/sdk/src/oauth-register-dynamic.test.ts+385−3
@@ -25,6 +25,18 @@ const TEMPLATE = AuthTemplateSlug.make("oauth"); · 24 unmodified lines
25const CLIENT = OAuthClientSlug.make("acme-dcr");25const CLIENT = OAuthClientSlug.make("acme-dcr");
26const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback";26const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback";
27 27
28const 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
36const registerRequestCount = (
37 requests: readonly { readonly path: string; readonly method: string }[],
38): number => requests.filter((r) => r.path === "/register" && r.method === "POST").length;
39
28const oauthPlugin = definePlugin(() => ({40const oauthPlugin = definePlugin(() => ({
29 id: "acme" as const,41 id: "acme" as const,
30 storage: () => ({}),42 storage: () => ({}),
@@ -56,6 +68,7 @@ describe("oauth.registerDynamicClient", () => { · 25 unmodified lines
56 // Probe surfaces the registration endpoint + advertised auth methods so68 // Probe surfaces the registration endpoint + advertised auth methods so
57 // the caller knows a public client is allowed.69 // the caller knows a public client is allowed.
58 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });70 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
71 expect(probe.issuer).toBe(server.issuerUrl);
59 expect(probe.registrationEndpoint).toBe(server.registrationEndpoint);72 expect(probe.registrationEndpoint).toBe(server.registrationEndpoint);
60 expect(probe.tokenEndpointAuthMethodsSupported).toContain("none");73 expect(probe.tokenEndpointAuthMethodsSupported).toContain("none");
61 expect(probe.resource).toBe(server.mcpResourceUrl);74 expect(probe.resource).toBe(server.mcpResourceUrl);
@@ -64,6 +77,7 @@ describe("oauth.registerDynamicClient", () => { · 2 unmodified lines
64 const slug = yield* executor.oauth.registerDynamicClient({77 const slug = yield* executor.oauth.registerDynamicClient({
65 owner: "org",78 owner: "org",
66 slug: CLIENT,79 slug: CLIENT,
80 issuer: probe.issuer,
67 registrationEndpoint: probe.registrationEndpoint!,81 registrationEndpoint: probe.registrationEndpoint!,
68 authorizationUrl: probe.authorizationUrl,82 authorizationUrl: probe.authorizationUrl,
69 tokenUrl: probe.tokenUrl,83 tokenUrl: probe.tokenUrl,
@@ -74,12 +88,13 @@ describe("oauth.registerDynamicClient", () => { · 4 unmodified lines
74 redirectUri: FLOW_REDIRECT_URI,88 redirectUri: FLOW_REDIRECT_URI,
75 originIntegration: INTEG,89 originIntegration: INTEG,
76 });90 });
77 expect(String(slug)).toBe(String(CLIENT));91 const expectedSlug = dcrSlugFor(server.issuerUrl);
92 expect(String(slug)).toBe(String(expectedSlug));
78 93
79 // The minted client appears in listClients with a server-issued94 // The minted client appears in listClients with a server-issued
80 // client_id and NO secret ever projected.95 // client_id and NO secret ever projected.
81 const clients = yield* executor.oauth.listClients();96 const clients = yield* executor.oauth.listClients();
82 const minted = clients.find((c) => String(c.slug) === String(CLIENT));97 const minted = clients.find((c) => String(c.slug) === String(expectedSlug));
83 expect(minted).toBeDefined();98 expect(minted).toBeDefined();
84 expect(minted!.owner).toBe("org");99 expect(minted!.owner).toBe("org");
85 expect(minted!.grant).toBe("authorization_code");100 expect(minted!.grant).toBe("authorization_code");
@@ -98,7 +113,7 @@ describe("oauth.registerDynamicClient", () => { · 12 unmodified lines
98 // PKCE flow with NO client_secret on the token exchange.113 // PKCE flow with NO client_secret on the token exchange.
99 const started = yield* executor.oauth.start({114 const started = yield* executor.oauth.start({
100 owner: "org",115 owner: "org",
101 client: CLIENT,116 client: expectedSlug,
102 clientOwner: "org",117 clientOwner: "org",
103 name: ConnectionName.make("main"),118 name: ConnectionName.make("main"),
104 integration: INTEG,119 integration: INTEG,
@@ -145,6 +160,373 @@ describe("oauth.registerDynamicClient", () => { · 40 unmodified lines
145 ),160 ),
146 );161 );
147 162
163 it.effect("reuses an existing DCR client for the same owner and authorization server", () =>
164 Effect.scoped(
165 Effect.gen(function* () {
166 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
167 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
168 yield* executor.acme.seed();
169 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
170
171 const first = yield* executor.oauth.registerDynamicClient({
172 owner: "org",
173 slug: OAuthClientSlug.make("first-attempt"),
174 issuer: probe.issuer,
175 registrationEndpoint: probe.registrationEndpoint!,
176 authorizationUrl: probe.authorizationUrl,
177 tokenUrl: probe.tokenUrl,
178 resource: probe.resource,
179 scopes: ["read"],
180 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
181 clientName: "Acme DCR",
182 redirectUri: FLOW_REDIRECT_URI,
183 originIntegration: INTEG,
184 });
185 yield* server.clearRequests;
186
187 const second = yield* executor.oauth.registerDynamicClient({
188 owner: "org",
189 slug: OAuthClientSlug.make("second-attempt"),
190 issuer: probe.issuer,
191 registrationEndpoint: probe.registrationEndpoint!,
192 authorizationUrl: probe.authorizationUrl,
193 tokenUrl: probe.tokenUrl,
194 resource: probe.resource,
195 scopes: ["read"],
196 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
197 clientName: "Other integration DCR",
198 redirectUri: FLOW_REDIRECT_URI,
199 originIntegration: IntegrationSlug.make("other"),
200 });
201
202 expect(second).toBe(first);
203 const requests = yield* server.requests;
204 expect(registerRequestCount(requests)).toBe(0);
205 }),
206 ),
207 );
208
209 it.effect("does not reuse a DCR client across owners", () =>
210 Effect.scoped(
211 Effect.gen(function* () {
212 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
213 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
214 yield* executor.acme.seed();
215 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
216
217 const orgSlug = yield* executor.oauth.registerDynamicClient({
218 owner: "org",
219 slug: OAuthClientSlug.make("org-attempt"),
220 issuer: probe.issuer,
221 registrationEndpoint: probe.registrationEndpoint!,
222 authorizationUrl: probe.authorizationUrl,
223 tokenUrl: probe.tokenUrl,
224 resource: probe.resource,
225 scopes: ["read"],
226 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
227 clientName: "Org DCR",
228 redirectUri: FLOW_REDIRECT_URI,
229 originIntegration: INTEG,
230 });
231 yield* server.clearRequests;
232
233 const userSlug = yield* executor.oauth.registerDynamicClient({
234 owner: "user",
235 slug: OAuthClientSlug.make("user-attempt"),
236 issuer: probe.issuer,
237 registrationEndpoint: probe.registrationEndpoint!,
238 authorizationUrl: probe.authorizationUrl,
239 tokenUrl: probe.tokenUrl,
240 resource: probe.resource,
241 scopes: ["read"],
242 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
243 clientName: "User DCR",
244 redirectUri: FLOW_REDIRECT_URI,
245 originIntegration: INTEG,
246 });
247
248 expect(String(userSlug)).toBe(String(orgSlug));
249 const requests = yield* server.requests;
250 expect(registerRequestCount(requests)).toBe(1);
251 }),
252 ),
253 );
254
255 it.effect("does NOT reuse a legacy DCR row with no stored issuer (mints fresh)", () =>
256 Effect.scoped(
257 Effect.gen(function* () {
258 // Post-migration, the reuse lookup keys strictly on a non-null
259 // origin_issuer: the fuzzy token-host fallback is gone. A legacy row that
260 // has not yet been backfilled (null origin_issuer) is therefore NOT
261 // reused, so a fresh DCR registration happens. The GC migration then
262 // backfills/GCs any duplicate this transient window mints.
263 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
264 const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
265 yield* executor.acme.seed();
266 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
267 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
268
269 yield* executor.oauth.createClient({
270 owner: "org",
271 slug: legacySlug,
272 authorizationUrl: probe.authorizationUrl,
273 tokenUrl: probe.tokenUrl,
274 resource: server.mcpResourceUrl,
275 grant: "authorization_code",
276 clientId: "legacy-dcr-client",
277 clientSecret: "",
278 });
279 yield* Effect.promise(() =>
280 config.db.updateMany("oauth_client", {
281 where: (b) => b("slug", "=", String(legacySlug)),
282 set: { origin_kind: null, origin_integration: null, origin_issuer: null },
283 }),
284 );
285 yield* server.clearRequests;
286
287 const registered = yield* executor.oauth.registerDynamicClient({
288 owner: "org",
289 slug: OAuthClientSlug.make("new-attempt"),
290 issuer: probe.issuer,
291 registrationEndpoint: probe.registrationEndpoint!,
292 authorizationUrl: probe.authorizationUrl,
293 tokenUrl: probe.tokenUrl,
294 resource: server.mcpResourceUrl,
295 scopes: ["read"],
296 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
297 clientName: "Acme DCR",
298 redirectUri: FLOW_REDIRECT_URI,
299 originIntegration: INTEG,
300 });
301
302 // A fresh client is registered, not the null-issuer legacy row.
303 expect(registered).not.toBe(legacySlug);
304 const requests = yield* server.requests;
305 expect(registerRequestCount(requests)).toBe(1);
306 }),
307 ),
308 );
309
310 it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () =>
311 Effect.scoped(
312 Effect.gen(function* () {
313 // The post-backfill counterpart: after the GC migration stamps a legacy
314 // row's origin_issuer, the reuse lookup keys on it and mints no
315 // duplicate. This is the steady state the migration establishes.
316 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
317 const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
318 yield* executor.acme.seed();
319 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
320 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
321
322 yield* executor.oauth.createClient({
323 owner: "org",
324 slug: legacySlug,
325 authorizationUrl: probe.authorizationUrl,
326 tokenUrl: probe.tokenUrl,
327 resource: server.mcpResourceUrl,
328 grant: "authorization_code",
329 clientId: "legacy-dcr-client",
330 clientSecret: "",
331 });
332 // Simulate the migration's backfill: legacy DCR stamp + issuer set.
333 yield* Effect.promise(() =>
334 config.db.updateMany("oauth_client", {
335 where: (b) => b("slug", "=", String(legacySlug)),
336 set: {
337 origin_kind: "dynamic_client_registration",
338 origin_integration: null,
339 origin_issuer: probe.issuer,
340 },
341 }),
342 );
343 yield* server.clearRequests;
344
345 const reused = yield* executor.oauth.registerDynamicClient({
346 owner: "org",
347 slug: OAuthClientSlug.make("new-attempt"),
348 issuer: probe.issuer,
349 registrationEndpoint: probe.registrationEndpoint!,
350 authorizationUrl: probe.authorizationUrl,
351 tokenUrl: probe.tokenUrl,
352 resource: server.mcpResourceUrl,
353 scopes: ["read"],
354 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
355 clientName: "Acme DCR",
356 redirectUri: FLOW_REDIRECT_URI,
357 originIntegration: INTEG,
358 });
359
360 expect(reused).toBe(legacySlug);
361 const requests = yield* server.requests;
362 expect(registerRequestCount(requests)).toBe(0);
363 }),
364 ),
365 );
366
367 it.effect("uses resource to distinguish DCR clients only after an issuer already differs", () =>
368 Effect.scoped(
369 Effect.gen(function* () {
370 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
371 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
372 yield* executor.acme.seed();
373 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
374 const resourceA = `${server.issuerUrl}/mcp/a`;
375 const resourceB = `${server.issuerUrl}/mcp/b`;
376
377 const first = yield* executor.oauth.registerDynamicClient({
378 owner: "org",
379 slug: OAuthClientSlug.make("first-resource"),
380 issuer: probe.issuer,
381 registrationEndpoint: probe.registrationEndpoint!,
382 authorizationUrl: probe.authorizationUrl,
383 tokenUrl: probe.tokenUrl,
384 resource: resourceA,
385 scopes: ["read"],
386 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
387 clientName: "Resource A",
388 redirectUri: FLOW_REDIRECT_URI,
389 originIntegration: INTEG,
390 });
391 expect(String(first)).toBe(String(dcrSlugFor(server.issuerUrl)));
392
393 const second = yield* executor.oauth.registerDynamicClient({
394 owner: "org",
395 slug: OAuthClientSlug.make("second-resource"),
396 issuer: probe.issuer,
397 registrationEndpoint: probe.registrationEndpoint!,
398 authorizationUrl: probe.authorizationUrl,
399 tokenUrl: probe.tokenUrl,
400 resource: resourceB,
401 scopes: ["read"],
402 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
403 clientName: "Resource B",
404 redirectUri: FLOW_REDIRECT_URI,
405 originIntegration: INTEG,
406 });
407 expect(String(second)).not.toBe(String(first));
408 expect(String(second)).toContain(String(first));
409 yield* server.clearRequests;
410
411 const third = yield* executor.oauth.registerDynamicClient({
412 owner: "org",
413 slug: OAuthClientSlug.make("third-resource"),
414 issuer: probe.issuer,
415 registrationEndpoint: probe.registrationEndpoint!,
416 authorizationUrl: probe.authorizationUrl,
417 tokenUrl: probe.tokenUrl,
418 resource: resourceB,
419 scopes: ["read"],
420 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
421 clientName: "Resource B again",
422 redirectUri: FLOW_REDIRECT_URI,
423 originIntegration: IntegrationSlug.make("other"),
424 });
425
426 expect(third).toBe(second);
427 const requests = yield* server.requests;
428 expect(registerRequestCount(requests)).toBe(0);
429 }),
430 ),
431 );
432
433 it.effect("does not reuse a resource-scoped DCR client for a resource-less request", () =>
434 Effect.scoped(
435 Effect.gen(function* () {
436 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
437 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
438 yield* executor.acme.seed();
439 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
440 const resourceA = `${server.issuerUrl}/mcp/a`;
441 const resourceB = `${server.issuerUrl}/mcp/b`;
442
443 // Two resource-scoped clients for the same authorization server. The first
444 // takes the base `dcr-<host>` slug; the second gets a resource-suffixed one.
445 const scopedA = yield* executor.oauth.registerDynamicClient({
446 owner: "org",
447 slug: OAuthClientSlug.make("scoped-a"),
448 issuer: probe.issuer,
449 registrationEndpoint: probe.registrationEndpoint!,
450 authorizationUrl: probe.authorizationUrl,
451 tokenUrl: probe.tokenUrl,
452 resource: resourceA,
453 scopes: ["read"],
454 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
455 clientName: "Resource A",
456 redirectUri: FLOW_REDIRECT_URI,
457 originIntegration: INTEG,
458 });
459 const scopedB = yield* executor.oauth.registerDynamicClient({
460 owner: "org",
461 slug: OAuthClientSlug.make("scoped-b"),
462 issuer: probe.issuer,
463 registrationEndpoint: probe.registrationEndpoint!,
464 authorizationUrl: probe.authorizationUrl,
465 tokenUrl: probe.tokenUrl,
466 resource: resourceB,
467 scopes: ["read"],
468 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
469 clientName: "Resource B",
470 redirectUri: FLOW_REDIRECT_URI,
471 originIntegration: INTEG,
472 });
473 expect(String(scopedB)).not.toBe(String(scopedA));
474 yield* server.clearRequests;
475
476 // A resource-LESS request for the same owner + issuer must NOT borrow
477 // either resource-scoped client (their tokens are bound to a resource):
478 // it registers a fresh resource-less client.
479 const resourceLess = yield* executor.oauth.registerDynamicClient({
480 owner: "org",
481 slug: OAuthClientSlug.make("resource-less"),
482 issuer: probe.issuer,
483 registrationEndpoint: probe.registrationEndpoint!,
484 authorizationUrl: probe.authorizationUrl,
485 tokenUrl: probe.tokenUrl,
486 resource: null,
487 scopes: ["read"],
488 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
489 clientName: "Resource-less",
490 redirectUri: FLOW_REDIRECT_URI,
491 originIntegration: INTEG,
492 });
493 expect(String(resourceLess)).not.toBe(String(scopedA));
494 expect(String(resourceLess)).not.toBe(String(scopedB));
495 const requests = yield* server.requests;
496 expect(registerRequestCount(requests)).toBe(1);
497
498 // The minted resource-less client is stored with a null resource, and the
499 // two resource-scoped clients still exist (nothing was clobbered).
500 const clients = yield* executor.oauth.listClients();
501 const minted = clients.find((c) => String(c.slug) === String(resourceLess));
502 expect(minted).toBeDefined();
503 expect(minted!.resource ?? null).toBeNull();
504 const slugs = clients.map((c) => String(c.slug));
505 expect(slugs).toContain(String(scopedA));
506 expect(slugs).toContain(String(scopedB));
507
508 // A SECOND resource-less request now reuses the resource-less client.
509 yield* server.clearRequests;
510 const reused = yield* executor.oauth.registerDynamicClient({
511 owner: "org",
512 slug: OAuthClientSlug.make("resource-less-again"),
513 issuer: probe.issuer,
514 registrationEndpoint: probe.registrationEndpoint!,
515 authorizationUrl: probe.authorizationUrl,
516 tokenUrl: probe.tokenUrl,
517 resource: null,
518 scopes: ["read"],
519 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
520 clientName: "Resource-less again",
521 redirectUri: FLOW_REDIRECT_URI,
522 originIntegration: IntegrationSlug.make("other"),
523 });
524 expect(String(reused)).toBe(String(resourceLess));
525 expect(registerRequestCount(yield* server.requests)).toBe(0);
526 }),
527 ),
528 );
529
148 // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only530 // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only
149 // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN531 // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN
150 // origin trips `invalid_redirect_uri`. The failure must explain the loopback532 // 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
70 type OAuthEndpointUrlPolicy,70 type OAuthEndpointUrlPolicy,
71} from "./oauth-helpers";71} from "./oauth-helpers";
72import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth";72import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth";
73import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc";
73 74
74/** Connection-minting input for the OAuth flow — extends a connection create75/** Connection-minting input for the OAuth flow — extends a connection create
75 * with the OAuth lifecycle fields (client slug, refresh material, expiry,76 * with the OAuth lifecycle fields (client slug, refresh material, expiry,
@@ -239,6 +240,68 @@ const clientOwnerFromPayload = (payload: unknown): Owner | null => { · 163 unmodified lines
239const parseGrant = (grant: unknown): OAuthGrant | null =>240const parseGrant = (grant: unknown): OAuthGrant | null =>
240 grant === "client_credentials" || grant === "authorization_code" ? grant : null;241 grant === "client_credentials" || grant === "authorization_code" ? grant : null;
241 242
243const 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
253const issuerOrigin = (issuer: string): string | null => parseUrl(issuer)?.origin ?? null;
254
255const issuerIsOriginOnly = (issuer: string): boolean => issuerOrigin(issuer) === issuer;
256
257const dcrIssuerMatches = (rowIssuer: string, inputIssuer: string | null): boolean =>
258 inputIssuer !== null &&
259 (rowIssuer === inputIssuer ||
260 (issuerIsOriginOnly(inputIssuer) && issuerOrigin(rowIssuer) === inputIssuer));
261
262const slugifyOAuthKey = (value: string): string =>
263 value
264 .toLowerCase()
265 .replace(/[^a-z0-9]+/g, "-")
266 .replace(/^-+|-+$/g, "");
267
268const 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
277const 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. */
297const 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
242const parseOAuthClientOrigin = (row: {305const parseOAuthClientOrigin = (row: {
243 readonly slug?: unknown;306 readonly slug?: unknown;
244 readonly grant?: unknown;307 readonly grant?: unknown;
@@ -246,26 +309,27 @@ const parseOAuthClientOrigin = (row: { · 1 unmodified line
246 readonly origin_kind?: unknown;309 readonly origin_kind?: unknown;
247 readonly origin_integration?: unknown;310 readonly origin_integration?: unknown;
248}): OAuthClientOrigin => {311}): OAuthClientOrigin => {
249 if (row.origin_kind === "dynamic_client_registration") {312 // Shared DCR classification (explicit origin_kind OR the legacy MCP-shaped
313 // heuristic) lives in `oauth-gc` so the runtime and the GC/backfill
314 // migrations agree exactly on what counts as a DCR row.
315 if (!isDcrClassifiedRow(row)) {
250 return {316 return {
251 kind: "dynamic_client_registration",317 kind: "manual",
252 integration:318 integration:
253 row.origin_integration == null319 row.origin_integration == null
254 ? null320 ? null
255 : IntegrationSlug.make(String(row.origin_integration)),321 : IntegrationSlug.make(String(row.origin_integration)),
256 };322 };
257 }323 }
258 const slug = row.slug == null ? "" : String(row.slug);324 // An explicit-origin DCR row carries its requesting integration; a
259 const resource = row.resource == null ? "" : String(row.resource);325 // heuristic-classified legacy row (null origin_kind) has none.
260 if (326 return {
261 row.origin_kind == null &&327 kind: "dynamic_client_registration",
262 row.grant === "authorization_code" &&328 integration:
263 /(^|[-_])mcp($|[-_])/.test(slug) &&329 row.origin_kind === "dynamic_client_registration" && row.origin_integration != null
264 /(^|\/)mcp($|[/?#])/.test(resource)330 ? IntegrationSlug.make(String(row.origin_integration))
265 ) {331 : null,
266 return { kind: "dynamic_client_registration", integration: null };332 };
267 }
268 return { kind: "manual" };
269};333};
270 334
271interface LoadedOAuthClient {335interface LoadedOAuthClient {
@@ -512,11 +576,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 240 unmodified lines
512 client_secret_item_id: clientSecretItemIdValue,576 client_secret_item_id: clientSecretItemIdValue,
513 resource: input.resource ?? null,577 resource: input.resource ?? null,
514 origin_kind: input.origin?.kind ?? "manual",578 origin_kind: input.origin?.kind ?? "manual",
579 // Recorded intent, kept for BOTH origins: a manual app registered from
580 // an integration's dialog stamps its integration so the picker can
581 // match it exactly, the same way a DCR client records the integration
582 // that requested it.
515 origin_integration:583 origin_integration:
584 input.origin?.integration == null ? null : String(input.origin.integration),
585 origin_issuer:
516 input.origin?.kind === "dynamic_client_registration"586 input.origin?.kind === "dynamic_client_registration"
517 ? input.origin.integration == null587 ? (canonicalIssuerUrl(input.originIssuer) ?? null)
518 ? null
519 : String(input.origin.integration)
520 : null,588 : null,
521 created_at: now,589 created_at: now,
522 }),590 }),
@@ -578,10 +646,128 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
578 ? "none"646 ? "none"
579 : "client_secret_post";647 : "client_secret_post";
580 648
649 type DcrReuseCandidate = {
650 readonly slug: OAuthClientSlug;
651 readonly resource: string | null;
652 };
653
654 // `oauth_client.created_at` is a date column that surfaces as a Date, an ISO
655 // string, or an epoch number depending on the storage backend. Normalize to
656 // epoch ms for deterministic oldest-first ordering; an unparseable/missing
657 // value sorts as 0 (oldest), so slug then breaks the tie stably.
658 const candidateCreatedAt = (value: unknown): number => {
659 if (value instanceof Date) {
660 const ms = value.getTime();
661 return Number.isFinite(ms) ? ms : 0;
662 }
663 if (typeof value === "number") return Number.isFinite(value) ? value : 0;
664 if (typeof value === "string") {
665 const ms = Date.parse(value);
666 return Number.isFinite(ms) ? ms : 0;
667 }
668 return 0;
669 };
670
671 const dcrCandidatesForIssuer = (
672 owner: Owner,
673 issuer: string | null,
674 ): Effect.Effect<readonly DcrReuseCandidate[], StorageFailure> =>
675 deps.fuma
676 .use("oauth_client.findMany", (db) =>
677 looseDb(db).findMany("oauth_client", {
678 where: (b: any) => b("owner", "=", owner),
679 }),
680 )
681 .pipe(
682 Effect.map((rows) => {
683 const matches = rows.flatMap(
684 (row): readonly (DcrReuseCandidate & { readonly createdAt: number })[] => {
685 if (parseOAuthClientOrigin(row).kind !== "dynamic_client_registration") return [];
686 // A candidate matches only via a non-null, canonicalized stored
687 // issuer. The GC migration backfills origin_issuer on every
688 // surviving DCR row, so post-migration a null-issuer row is a
689 // transient (unmigrated) row; skipping it just mints one duplicate
690 // the migration then GCs, rather than reusing on a fuzzy token-host
691 // guess.
692 const rowIssuer =
693 row.origin_issuer == null ? null : canonicalIssuerUrl(String(row.origin_issuer));
694 const issuerMatches = rowIssuer !== null && dcrIssuerMatches(rowIssuer, issuer);
695 if (!issuerMatches) return [];
696 return [
697 {
698 slug: OAuthClientSlug.make(String(row.slug)),
699 resource: row.resource == null ? null : String(row.resource),
700 createdAt: candidateCreatedAt(row.created_at),
701 },
702 ];
703 },
704 );
705 // Deterministic reuse order: oldest first, slug as a stable tiebreak
706 // when timestamps collide or are missing. Without this, which of
707 // several live duplicates sharing an (owner, issuer) gets reused is
708 // whatever order the storage backend returned rows in — the reuse
709 // pick must be stable across boots and backends.
710 return [...matches]
711 .sort(
712 (a, b) =>
713 a.createdAt - b.createdAt || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0),
714 )
715 .map(({ slug, resource }): DcrReuseCandidate => ({ slug, resource }));
716 }),
717 );
718
719 const decideDcrClientReuse = (
720 input: RegisterDynamicClientInput,
721 issuer: string | null,
722 ): Effect.Effect<
723 {
724 readonly existingSlug: OAuthClientSlug | null;
725 readonly registrationSlug: OAuthClientSlug;
726 },
727 StorageFailure
728 > =>
729 Effect.gen(function* () {
730 const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer);
731 const resource = input.resource ?? null;
732 if (resource !== null) {
733 const matchingResource = candidates.find((client) => client.resource === resource);
734 if (matchingResource) {
735 return { existingSlug: matchingResource.slug, registrationSlug: matchingResource.slug };
736 }
737 const slug = dcrClientSlug(issuer, candidates.length > 0 ? resource : null, input.slug);
738 return {
739 existingSlug: null,
740 registrationSlug: slug,
741 };
742 }
743
744 // Resource-less request: only reuse a resource-LESS candidate. A client
745 // minted for a specific RFC 8707 resource must NOT be reused for a
746 // resource-less flow (its tokens are bound to that resource), so when only
747 // resource-scoped candidates exist we register a fresh resource-less client
748 // rather than silently borrowing one (the old `?? candidates[0]` bug).
749 const reusable = candidates.find((client) => client.resource === null);
750 if (reusable) return { existingSlug: reusable.slug, registrationSlug: reusable.slug };
751 // Fresh resource-less client. Its slug is the bare `dcr-<host>` base, but
752 // the FIRST resource-scoped registration for an issuer also takes that base
753 // (dcrClientSlug only suffixes once candidates exist). `createClient`
754 // deletes any row with a colliding (owner, slug) first, so reusing the base
755 // here would silently clobber that resource-scoped client. Dedupe against
756 // the existing candidate slugs so the resource-less client keeps its own row.
757 const takenSlugs = new Set(candidates.map((client) => String(client.slug)));
758 const slug = uniqueDcrSlug(dcrClientSlug(issuer, null, input.slug), takenSlugs);
759 return { existingSlug: null, registrationSlug: slug };
760 });
761
581 const registerDynamicClient = (762 const registerDynamicClient = (
582 input: RegisterDynamicClientInput,763 input: RegisterDynamicClientInput,
583 ): Effect.Effect<OAuthClientSlug, OAuthRegisterDynamicError | StorageFailure> =>764 ): Effect.Effect<OAuthClientSlug, OAuthRegisterDynamicError | StorageFailure> =>
584 Effect.gen(function* () {765 Effect.gen(function* () {
766 const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint);
767 const reuse = yield* decideDcrClientReuse(input, issuer);
768 if (reuse.existingSlug !== null) return reuse.existingSlug;
769
770 const slug = reuse.registrationSlug;
585 const flowRedirectUri = input.redirectUri ?? redirectUri;771 const flowRedirectUri = input.redirectUri ?? redirectUri;
586 // DCR registers our callback as the client's redirect_uri — fail loudly772 // DCR registers our callback as the client's redirect_uri — fail loudly
587 // if the executor has none rather than registering a localhost URL.773 // if the executor has none rather than registering a localhost URL.
@@ -633,7 +819,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 45 unmodified lines
633 // stored client carries no scope set (the integration drives requests).819 // stored client carries no scope set (the integration drives requests).
634 yield* createClient({820 yield* createClient({
635 owner: input.owner,821 owner: input.owner,
636 slug: input.slug,822 slug,
637 authorizationUrl: input.authorizationUrl,823 authorizationUrl: input.authorizationUrl,
638 tokenUrl: input.tokenUrl,824 tokenUrl: input.tokenUrl,
639 resource: input.resource ?? null,825 resource: input.resource ?? null,
@@ -644,8 +830,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 4 unmodified lines
644 kind: "dynamic_client_registration",830 kind: "dynamic_client_registration",
645 integration: input.originIntegration ?? null,831 integration: input.originIntegration ?? null,
646 },832 },
833 originIssuer: issuer,
647 });834 });
648 return input.slug;835 return slug;
649 });836 });
650 837
651 // -----------------------------------------------------------------------838 // -----------------------------------------------------------------------
@@ -1158,6 +1345,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 506 unmodified lines
1158 });1345 });
1159 }1346 }
1160 return {1347 return {
1348 issuer: as.metadata.issuer,
1161 authorizationUrl: as.metadata.authorization_endpoint,1349 authorizationUrl: as.metadata.authorization_endpoint,
1162 tokenUrl: as.metadata.token_endpoint,1350 tokenUrl: as.metadata.token_endpoint,
1163 resource: resource?.metadata.resource ?? null,1351 resource: resource?.metadata.resource ?? null,
packages/core/sdk/src/shared.ts+1−0
@@ -127,6 +127,7 @@ export { · 126 unmodified lines
127 type OAuthGrant,127 type OAuthGrant,
128 type OAuthAuthentication,128 type OAuthAuthentication,
129 type OAuthClient,129 type OAuthClient,
130 type OAuthClientOrigin,
130 type OAuthClientSummary,131 type OAuthClientSummary,
131 type CreateOAuthClientInput,132 type CreateOAuthClientInput,
132 type RegisterDynamicClientInput,133 type RegisterDynamicClientInput,
packages/core/sdk/src/sqlite-oauth-client-gc-migration.test.ts+351−0
@@ -0,0 +1,351 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Fiber } from "effect";
3
4import { collectTables } from "./executor";
5import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "./sqlite-test-db";
6import { 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
13const TENANT = "t1";
14const OWNER = "org";
15const SUBJECT = "s1";
16
17const 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
26const 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
59const 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
86const 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
91const 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
100const 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
109describe("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
21import { Effect } from "effect";
22
23import {
24 DataMigrationError,
25 type SqliteDataMigration,
26 type SqliteDataMigrationClient,
27} from "./sqlite-data-migrations";
28import { classifyOAuthClientGc, isDcrClassifiedRow, registrableOriginOfUrl } from "./oauth-gc";
29
30const MIGRATION_NAME = "2026-07-02-gc-dead-dcr-oauth-clients";
31
32const 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
41const 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 */
62export 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. */
184export 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 @@
1{1{
2 "name": "@executor-js/vite-plugin",2 "name": "@executor-js/vite-plugin",
3 "version": "0.0.44",3 "version": "0.0.44",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/core/vite-plugin",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/core/vite-plugin"12 "directory": "packages/core/vite-plugin"
13 },13 },
14 "files": [14 "files": [
packages/hosts/cloudflare/package.json+1−0
@@ -37,6 +37,7 @@ · 36 unmodified lines
37 "@types/node": "catalog:",37 "@types/node": "catalog:",
38 "bun-types": "catalog:",38 "bun-types": "catalog:",
39 "typescript": "catalog:",39 "typescript": "catalog:",
40 "urlpattern-polyfill": "^10.1.0",
40 "vitest": "catalog:"41 "vitest": "catalog:"
41 }42 }
42}43}
packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts+360−0
@@ -0,0 +1,360 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp";
3import { Effect, Option, Schema } from "effect";
4
5const KEEPALIVE_INTERVAL_MS = 25_000;
6const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
7
8type 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
16type 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
22type 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
30const encoder = new TextEncoder();
31const 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});
42const decodeRotationLogEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RotationLogEvent));
43
44const flushMicrotasks = async (): Promise<void> => {
45 await Promise.resolve();
46 await Promise.resolve();
47 await Promise.resolve();
48};
49
50const 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
58const 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
84const 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
121const makeExecutionContext = (): ExecutionContext => ({
122 passThroughOnException: () => {},
123 props: undefined,
124 waitUntil: () => {},
125});
126
127const 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
142const makeAgentStub = (ws: FakeWebSocket): FakeAgentStub => ({
143 setName: async () => {},
144 getInitializeRequest: async () => ({}),
145 fetch: async () => ({ webSocket: ws }),
146});
147
148const 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
158const 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
185const 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
222const 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
234const emitClose = (ws: FakeWebSocket): void => {
235 ws.dispatchEvent(new Event("close"));
236};
237
238const rotationLogs = (logs: ReadonlyArray<string>): ReadonlyArray<RotationLog> =>
239 logs.flatMap((line) => {
240 const decoded = decodeRotationLogEvent(line);
241 return Option.isSome(decoded) ? [decoded.value] : [];
242 });
243
244describe("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 @@
1import { URLPattern } from "urlpattern-polyfill";
2
3type UrlPatternPolyfillGlobal = Omit<typeof globalThis, "URLPattern"> & {
4 URLPattern?: typeof URLPattern;
5};
6
7const globals = globalThis as UrlPatternPolyfillGlobal;
8
9globals.URLPattern ??= URLPattern;
packages/hosts/cloudflare/src/test-stubs/cloudflare-email.ts+7−0
@@ -0,0 +1,7 @@
1export 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 @@
1export const env: Record<string, unknown> = {};
2
3export class DurableObject {}
4
5export class RpcTarget {}
6
7export class WorkerEntrypoint {}
8
9export const exports: Record<string, unknown> = {};
packages/hosts/cloudflare/vitest.config.ts+16−0
@@ -1,8 +1,24 @@
1import { dirname, resolve } from "node:path";
2import { fileURLToPath } from "node:url";
1import { defineConfig } from "vitest/config";3import { defineConfig } from "vitest/config";
2 4
5const __dirname = dirname(fileURLToPath(import.meta.url));
6
3export default defineConfig({7export default defineConfig({
8 resolve: {
9 alias: {
10 "cloudflare:email": resolve(__dirname, "./src/test-stubs/cloudflare-email.ts"),
11 "cloudflare:workers": resolve(__dirname, "./src/test-stubs/cloudflare-workers.ts"),
12 },
13 },
4 test: {14 test: {
5 include: ["src/**/*.test.ts"],15 include: ["src/**/*.test.ts"],
6 passWithNoTests: true,16 passWithNoTests: true,
17 setupFiles: ["./src/test-setup.ts"],
18 server: {
19 deps: {
20 inline: ["agents", "partyserver"],
21 },
22 },
7 },23 },
8});24});
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
37 37
38## Status38## Status
39 39
40Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).40Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
41 41
42## License42## License
43 43
packages/kernel/core/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/codemode-core",2 "name": "@executor-js/codemode-core",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/kernel/core",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/kernel/core"12 "directory": "packages/kernel/core"
13 },13 },
14 "files": [14 "files": [
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
52 52
53## Status53## Status
54 54
55Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).55Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
56 56
57## License57## License
58 58
packages/kernel/runtime-quickjs/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/runtime-quickjs",2 "name": "@executor-js/runtime-quickjs",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/kernel/runtime-quickjs",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/kernel/runtime-quickjs"12 "directory": "packages/kernel/runtime-quickjs"
13 },13 },
14 "files": [14 "files": [
packages/plugins/desktop-settings/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-desktop-settings",2 "name": "@executor-js/plugin-desktop-settings",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/desktop-settings",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/desktop-settings"12 "directory": "packages/plugins/desktop-settings"
13 },13 },
14 "files": [14 "files": [
packages/plugins/example/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-example",2 "name": "@executor-js/plugin-example",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/example",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/example"12 "directory": "packages/plugins/example"
13 },13 },
14 "files": [14 "files": [
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
52 52
53## Status53## Status
54 54
55Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).55Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
56 56
57## License57## License
58 58
packages/plugins/file-secrets/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-file-secrets",2 "name": "@executor-js/plugin-file-secrets",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/file-secrets",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/file-secrets"12 "directory": "packages/plugins/file-secrets"
13 },13 },
14 "files": [14 "files": [
packages/plugins/google/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-google",2 "name": "@executor-js/plugin-google",
3 "version": "1.5.26",3 "version": "1.5.26",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/google",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/google",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/google"12 "directory": "packages/plugins/google"
13 },13 },
14 "files": [14 "files": [
packages/plugins/graphql/README.md+1−1
@@ -75,7 +75,7 @@ import { graphqlPlugin } from "@executor-js/plugin-graphql/core"; · 74 unmodified lines
75 75
76## Status76## Status
77 77
78Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).78Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
79 79
80## License80## License
81 81
packages/plugins/graphql/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-graphql",2 "name": "@executor-js/plugin-graphql",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/graphql",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/graphql"12 "directory": "packages/plugins/graphql"
13 },13 },
14 "files": [14 "files": [
packages/plugins/keychain/README.md+1−1
@@ -52,7 +52,7 @@ import { keychainPlugin } from "@executor-js/plugin-keychain/core"; · 51 unmodified lines
52 52
53## Status53## Status
54 54
55Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).55Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
56 56
57## License57## License
58 58
packages/plugins/keychain/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-keychain",2 "name": "@executor-js/plugin-keychain",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/keychain",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/keychain"12 "directory": "packages/plugins/keychain"
13 },13 },
14 "files": [14 "files": [
packages/plugins/mcp/README.md+1−1
@@ -60,7 +60,7 @@ import { mcpPlugin } from "@executor-js/plugin-mcp/core"; · 59 unmodified lines
60 60
61## Status61## Status
62 62
63Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).63Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
64 64
65## License65## License
66 66
packages/plugins/mcp/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-mcp",2 "name": "@executor-js/plugin-mcp",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/mcp",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/mcp"12 "directory": "packages/plugins/mcp"
13 },13 },
14 "files": [14 "files": [
packages/plugins/microsoft/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-microsoft",2 "name": "@executor-js/plugin-microsoft",
3 "version": "1.5.26",3 "version": "1.5.26",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/microsoft",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/microsoft",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/microsoft"12 "directory": "packages/plugins/microsoft"
13 },13 },
14 "files": [14 "files": [
packages/plugins/onepassword/README.md+1−1
@@ -72,7 +72,7 @@ import { onepasswordPlugin } from "@executor-js/plugin-onepassword/core"; · 71 unmodified lines
72 72
73## Status73## Status
74 74
75Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).75Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
76 76
77## License77## License
78 78
packages/plugins/onepassword/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-onepassword",2 "name": "@executor-js/plugin-onepassword",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/onepassword",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/onepassword"12 "directory": "packages/plugins/onepassword"
13 },13 },
14 "files": [14 "files": [
packages/plugins/openapi/README.md+1−1
@@ -76,7 +76,7 @@ import { openApiPlugin } from "@executor-js/plugin-openapi/core"; · 75 unmodified lines
76 76
77## Status77## Status
78 78
79Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/RhysSullivan/executor).79Pre-`1.0`. APIs may still change between beta releases. Part of the [executor monorepo](https://github.com/UsefulSoftwareCo/executor).
80 80
81## License81## License
82 82
packages/plugins/openapi/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-openapi",2 "name": "@executor-js/plugin-openapi",
3 "version": "1.5.27",3 "version": "1.5.27",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/openapi",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/openapi"12 "directory": "packages/plugins/openapi"
13 },13 },
14 "files": [14 "files": [
packages/plugins/toolkits/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-toolkits",2 "name": "@executor-js/plugin-toolkits",
3 "version": "1.5.19",3 "version": "1.5.19",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/toolkits",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/toolkits"12 "directory": "packages/plugins/toolkits"
13 },13 },
14 "files": [14 "files": [
packages/plugins/workos-vault/package.json+3−3
@@ -1,14 +1,14 @@
1{1{
2 "name": "@executor-js/plugin-workos-vault",2 "name": "@executor-js/plugin-workos-vault",
3 "version": "0.0.2",3 "version": "0.0.2",
4 "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/workos-vault",4 "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/workos-vault",
5 "bugs": {5 "bugs": {
6 "url": "https://github.com/RhysSullivan/executor/issues"6 "url": "https://github.com/UsefulSoftwareCo/executor/issues"
7 },7 },
8 "license": "MIT",8 "license": "MIT",
9 "repository": {9 "repository": {
10 "type": "git",10 "type": "git",
11 "url": "git+https://github.com/RhysSullivan/executor.git",11 "url": "git+https://github.com/UsefulSoftwareCo/executor.git",
12 "directory": "packages/plugins/workos-vault"12 "directory": "packages/plugins/workos-vault"
13 },13 },
14 "files": [14 "files": [
packages/react/src/api/atoms.tsx+4−1
@@ -500,6 +500,7 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 499 unmodified lines
500 readonly grant: OAuthGrant;500 readonly grant: OAuthGrant;
501 readonly clientId: string;501 readonly clientId: string;
502 readonly resource?: string | null;502 readonly resource?: string | null;
503 readonly originIntegration?: IntegrationSlug | null;
503 };504 };
504 },505 },
505 ) =>506 ) =>
@@ -512,7 +513,9 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 6 unmodified lines
512 tokenUrl: arg.payload.tokenUrl,513 tokenUrl: arg.payload.tokenUrl,
513 resource: arg.payload.resource ?? null,514 resource: arg.payload.resource ?? null,
514 clientId: arg.payload.clientId,515 clientId: arg.payload.clientId,
515 origin: { kind: "manual" },516 // Mirror the server's stamp so the just-registered app matches its
517 // integration in the picker immediately (before the refetch lands).
518 origin: { kind: "manual", integration: arg.payload.originIntegration ?? null },
516 };519 };
517 const index = rows.findIndex(520 const index = rows.findIndex(
518 (client) => client.owner === summary.owner && client.slug === summary.slug,521 (client) => client.owner === summary.owner && client.slug === summary.slug,
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
31});31});
32 32
33type ProbeResult = {33type ProbeResult = {
34 readonly issuer?: string | null;
34 readonly authorizationUrl: string;35 readonly authorizationUrl: string;
35 readonly tokenUrl: string;36 readonly tokenUrl: string;
36 readonly resource?: string | null;37 readonly resource?: string | null;
@@ -42,6 +43,7 @@ type ProbeResult = { · 5 unmodified lines
42type RegisterArgs = {43type RegisterArgs = {
43 readonly owner: Owner;44 readonly owner: Owner;
44 readonly slug: OAuthClientSlug;45 readonly slug: OAuthClientSlug;
46 readonly issuer?: string | null;
45 readonly registrationEndpoint: string;47 readonly registrationEndpoint: string;
46 readonly authorizationUrl: string;48 readonly authorizationUrl: string;
47 readonly tokenUrl: string;49 readonly tokenUrl: string;
@@ -306,6 +308,7 @@ describe("runDcrConnect", () => { · 258 unmodified lines
306 const probe = (_url: string): Promise<ProbeResult | null> => {308 const probe = (_url: string): Promise<ProbeResult | null> => {
307 calls.push("probe");309 calls.push("probe");
308 return Promise.resolve({310 return Promise.resolve({
311 issuer: "https://auth.example.com",
309 authorizationUrl: "https://auth.example.com/authorize",312 authorizationUrl: "https://auth.example.com/authorize",
310 tokenUrl: "https://auth.example.com/token",313 tokenUrl: "https://auth.example.com/token",
311 resource: "https://mcp.example.com/mcp",314 resource: "https://mcp.example.com/mcp",
@@ -330,7 +333,6 @@ describe("runDcrConnect", () => { · 18 unmodified lines
330 discoveryUrl: "https://mcp.example.com/mcp",333 discoveryUrl: "https://mcp.example.com/mcp",
331 owner: "user",334 owner: "user",
332 integrationName: "Linear MCP",335 integrationName: "Linear MCP",
333 existingSlugs: [],
334 redirectUri: "https://localhost:5394/api/oauth/callback",336 redirectUri: "https://localhost:5394/api/oauth/callback",
335 integration: TEST_INTEGRATION,337 integration: TEST_INTEGRATION,
336 },338 },
@@ -342,6 +344,8 @@ describe("runDcrConnect", () => { · 5 unmodified lines
342 // DCR always mints an authorization-code/PKCE client; callers do not choose344 // DCR always mints an authorization-code/PKCE client; callers do not choose
343 // a grant here.345 // a grant here.
344 expect(registerArgs).not.toBeNull();346 expect(registerArgs).not.toBeNull();
347 expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com");
348 expect(registerArgs!.issuer).toBe("https://auth.example.com");
345 expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register");349 expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register");
346 expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize");350 expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize");
347 expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token");351 expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token");
@@ -379,7 +383,6 @@ describe("runDcrConnect", () => { · 31 unmodified lines
379 resourceFallback: "https://mcp.example.com/mcp",383 resourceFallback: "https://mcp.example.com/mcp",
380 owner: "user",384 owner: "user",
381 integrationName: "App",385 integrationName: "App",
382 existingSlugs: [],
383 declaredScopes: ["declared.scope"],386 declaredScopes: ["declared.scope"],
384 integration: TEST_INTEGRATION,387 integration: TEST_INTEGRATION,
385 },388 },
@@ -416,7 +419,6 @@ describe("runDcrConnect", () => { · 30 unmodified lines
416 discoveryUrl: "https://auth.example.com/token",419 discoveryUrl: "https://auth.example.com/token",
417 owner: "user",420 owner: "user",
418 integrationName: "App",421 integrationName: "App",
419 existingSlugs: [],
420 integration: TEST_INTEGRATION,422 integration: TEST_INTEGRATION,
421 },423 },
422 );424 );
@@ -448,7 +450,6 @@ describe("runDcrConnect", () => { · 25 unmodified lines
448 discoveryUrl: "https://mcp.example.com/mcp",450 discoveryUrl: "https://mcp.example.com/mcp",
449 owner: "user",451 owner: "user",
450 integrationName: "App",452 integrationName: "App",
451 existingSlugs: [],
452 integration: TEST_INTEGRATION,453 integration: TEST_INTEGRATION,
453 },454 },
454 );455 );
@@ -483,7 +484,6 @@ describe("runDcrConnect", () => { · 28 unmodified lines
483 discoveryUrl: "https://mcp.example.com/mcp",484 discoveryUrl: "https://mcp.example.com/mcp",
484 owner: "user",485 owner: "user",
485 integrationName: "App",486 integrationName: "App",
486 existingSlugs: [],
487 integration: TEST_INTEGRATION,487 integration: TEST_INTEGRATION,
488 },488 },
489 );489 );
@@ -513,7 +513,6 @@ describe("runDcrConnect", () => { · 23 unmodified lines
513 discoveryUrl: "https://mcp.example.com/mcp",513 discoveryUrl: "https://mcp.example.com/mcp",
514 owner: "user",514 owner: "user",
515 integrationName: "App",515 integrationName: "App",
516 existingSlugs: [],
517 integration: TEST_INTEGRATION,516 integration: TEST_INTEGRATION,
518 },517 },
519 );518 );
@@ -552,7 +551,6 @@ describe("runDcrConnect", () => { · 32 unmodified lines
552 discoveryUrl: "https://mcp.example.com/mcp",551 discoveryUrl: "https://mcp.example.com/mcp",
553 owner: "user",552 owner: "user",
554 integrationName: "App",553 integrationName: "App",
555 existingSlugs: [],
556 integration: TEST_INTEGRATION,554 integration: TEST_INTEGRATION,
557 },555 },
558 );556 );
@@ -570,4 +568,37 @@ describe("runDcrConnect", () => { · 11 unmodified lines
570 });568 });
571 expect(started).toBe(false);569 expect(started).toBe(false);
572 });570 });
571
572 it("mints a deterministic server-keyed slug WITHOUT the picker's slug list", async () => {
573 // Part B removed the DCR connect path's dependency on the picker's app list:
574 // the slug is derived from the authorization server (Part A), so callers no
575 // longer thread `existingSlugs`. This omits it entirely and still succeeds.
576 let registerArgs: RegisterArgs | null = null;
577 const outcome = await runDcrConnect(
578 {
579 probe: (): Promise<ProbeResult | null> =>
580 Promise.resolve({
581 issuer: "https://auth.example.com",
582 authorizationUrl: "https://auth.example.com/authorize",
583 tokenUrl: "https://auth.example.com/token",
584 registrationEndpoint: "https://auth.example.com/register",
585 }),
586 register: (args: RegisterArgs): Promise<OAuthClientSlug | null> => {
587 registerArgs = args;
588 return Promise.resolve(OAuthClientSlug.make("app"));
589 },
590 start: (): void => {},
591 },
592 {
593 discoveryUrl: "https://mcp.example.com/mcp",
594 owner: "user",
595 integrationName: "App",
596 integration: TEST_INTEGRATION,
597 },
598 );
599 expect(outcome.kind).toBe("started");
600 expect(registerArgs).not.toBeNull();
601 // Slug comes from the issuer host, independent of any picker state.
602 expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com");
603 });
573});604});
packages/react/src/components/add-account-modal.tsx+159−14
@@ -62,6 +62,8 @@ import { · 61 unmodified lines
62import {62import {
63 clientDisplayName,63 clientDisplayName,
64 clientHost,64 clientHost,
65 optimisticDcrClientSlug,
66 selectDcrClientsForIntegration,
65 uniqueClientSlug,67 uniqueClientSlug,
66 useOAuthClientsForIntegration,68 useOAuthClientsForIntegration,
67 type OAuthClientOption,69 type OAuthClientOption,
@@ -79,13 +81,14 @@ import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; · 11 unmodified lines
79import { connectionIdentifier } from "../lib/connection-name";81import { connectionIdentifier } from "../lib/connection-name";
80import { Badge } from "./badge";82import { Badge } from "./badge";
81import { Button } from "./button";83import { Button } from "./button";
84import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./collapsible";
82import {85import {
83 DropdownMenu,86 DropdownMenu,
84 DropdownMenuContent,87 DropdownMenuContent,
85 DropdownMenuItem,88 DropdownMenuItem,
86 DropdownMenuTrigger,89 DropdownMenuTrigger,
87} from "./dropdown-menu";90} from "./dropdown-menu";
88import { EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react";91import { ChevronDown, EyeIcon, EyeOffIcon, PlusIcon, XIcon } from "lucide-react";
89import {92import {
90 Dialog,93 Dialog,
91 DialogContent,94 DialogContent,
@@ -668,6 +671,7 @@ export async function runCimdConnect( · 576 unmodified lines
668 671
669/** Discovery result from the probe step (subset of the `probeOAuth` response). */672/** Discovery result from the probe step (subset of the `probeOAuth` response). */
670type DcrProbeResult = {673type DcrProbeResult = {
674 readonly issuer?: string | null;
671 readonly authorizationUrl: string;675 readonly authorizationUrl: string;
672 readonly tokenUrl: string;676 readonly tokenUrl: string;
673 readonly resource?: string | null;677 readonly resource?: string | null;
@@ -679,6 +683,7 @@ type DcrProbeResult = { · 5 unmodified lines
679type DcrRegisterArgs = {683type DcrRegisterArgs = {
680 readonly owner: Owner;684 readonly owner: Owner;
681 readonly slug: OAuthClientSlug;685 readonly slug: OAuthClientSlug;
686 readonly issuer?: string | null;
682 readonly registrationEndpoint: string;687 readonly registrationEndpoint: string;
683 readonly authorizationUrl: string;688 readonly authorizationUrl: string;
684 readonly tokenUrl: string;689 readonly tokenUrl: string;
@@ -739,8 +744,6 @@ type RunDcrConnectInput = { · 54 unmodified lines
739 readonly resourceFallback?: string;744 readonly resourceFallback?: string;
740 readonly owner: Owner;745 readonly owner: Owner;
741 readonly integrationName: string;746 readonly integrationName: string;
742 /** The owner's existing client slugs, so the minted slug stays unique. */
743 readonly existingSlugs: readonly string[];
744 /** Scopes declared by the integration's method (override the probed ones). */747 /** Scopes declared by the integration's method (override the probed ones). */
745 readonly declaredScopes?: readonly string[];748 readonly declaredScopes?: readonly string[];
746 /** Browser-facing callback URL registered with DCR when available. */749 /** Browser-facing callback URL registered with DCR when available. */
@@ -773,11 +776,12 @@ export async function runDcrConnect( · 26 unmodified lines
773 const registrationEndpoint = probe.registrationEndpoint;776 const registrationEndpoint = probe.registrationEndpoint;
774 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe };777 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe };
775 778
776 const slug = uniqueClientSlug(input.integrationName, input.existingSlugs);779 const slug = optimisticDcrClientSlug(probe.issuer ?? registrationEndpoint);
777 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);780 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);
778 const minted = await deps.register({781 const minted = await deps.register({
779 owner: input.owner,782 owner: input.owner,
780 slug,783 slug,
784 issuer: probe.issuer ?? null,
781 registrationEndpoint,785 registrationEndpoint,
782 authorizationUrl: probe.authorizationUrl,786 authorizationUrl: probe.authorizationUrl,
783 tokenUrl: probe.tokenUrl,787 tokenUrl: probe.tokenUrl,
@@ -1160,6 +1164,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 376 unmodified lines
1160 // FIX 3 escape hatch: when no registered app matched the integration's1164 // FIX 3 escape hatch: when no registered app matched the integration's
1161 // endpoints, the unmatched apps are collapsed behind an opt-in expander.1165 // endpoints, the unmatched apps are collapsed behind an opt-in expander.
1162 const [showOtherApps, setShowOtherApps] = useState(false);1166 const [showOtherApps, setShowOtherApps] = useState(false);
1167 // Auto-registered (DCR) clients are plumbing, not pickable apps: they live in a
1168 // collapsed management section at the bottom of the OAuth tab, revealed on
1169 // demand so they can be inspected and deleted without cluttering the picker.
1170 const [showAutoRegistered, setShowAutoRegistered] = useState(false);
1163 // Inline OAuth app management — edit re-opens the registration form for an1171 // Inline OAuth app management — edit re-opens the registration form for an
1164 // existing app (upsert by owner+slug); remove confirms before deleting. Both1172 // existing app (upsert by owner+slug); remove confirms before deleting. Both
1165 // hold the FULL app summary (with endpoints + resource) so the edit prefill1173 // hold the FULL app summary (with endpoints + resource) so the edit prefill
@@ -1403,6 +1411,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 237 unmodified lines
1403 // unconditionally; in DCR mode the result is ignored until/unless we fall back.1411 // unconditionally; in DCR mode the result is ignored until/unless we fall back.
1404 const {1412 const {
1405 clients: oauthApps,1413 clients: oauthApps,
1414 nearMatches: oauthNearApps,
1406 otherClients: oauthOtherApps,1415 otherClients: oauthOtherApps,
1407 loading: oauthLoading,1416 loading: oauthLoading,
1408 endpointMatched: oauthEndpointMatched,1417 endpointMatched: oauthEndpointMatched,
@@ -1415,8 +1424,29 @@ function AddAccountModalView(props: AddAccountModalProps) { · 6 unmodified lines
1415 // unrelated provider's app.1424 // unrelated provider's app.
1416 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,1425 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
1417 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,1426 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1427 // Recorded intent: a manual app registered from THIS integration's dialog is
1428 // a tier-1 match regardless of host.
1429 integration,
1418 requireEndpointMatch: isDcr,1430 requireEndpointMatch: isDcr,
1419 });1431 });
1432 // Auto-registered (DCR) clients for THIS integration. Excluded from the picker
1433 // above, but surfaced in a collapsed management section so they stay
1434 // deletable. Reads the same optimistic list the picker does.
1435 const dcrClients = useMemo(
1436 () =>
1437 selectDcrClientsForIntegration(clientSummaries as readonly OAuthClientOption[], {
1438 integration,
1439 tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl,
1440 authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl,
1441 }),
1442 [
1443 clientSummaries,
1444 integration,
1445 method?.oauth?.tokenUrl,
1446 method?.oauth?.authorizationUrl,
1447 oauthFallbackProbe,
1448 ],
1449 );
1420 const oauthPopup = useOAuthPopupFlow({1450 const oauthPopup = useOAuthPopupFlow({
1421 popupName: "add-account-oauth",1451 popupName: "add-account-oauth",
1422 detectPopupClosed: false,1452 detectPopupClosed: false,
@@ -1433,8 +1463,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 10 unmodified lines
1433 // Editing reuses the registration form (createClient upserts by owner+slug),1463 // Editing reuses the registration form (createClient upserts by owner+slug),
1434 // so it occupies the same full-bleed sub-view as registering.1464 // so it occupies the same full-bleed sub-view as registering.
1435 const oauthEditing = isOAuth && editingClient !== null;1465 const oauthEditing = isOAuth && editingClient !== null;
1466 // Resolve the picked slug across every PICKABLE tier: exact matches, the
1467 // subdued near-match tier, and the unrelated escape-hatch apps. All three are
1468 // selectable (only the default auto-selection is restricted to tier 1), so the
1469 // connect button must resolve a client from any of them. DCR clients are not
1470 // in these lists, so they can never be chosen as the connect app.
1436 const chosenClient: OAuthClientOption | null =1471 const chosenClient: OAuthClientOption | null =
1437 oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null;1472 [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find(
1473 (c: OAuthClientOption) => String(c.slug) === selectedApp,
1474 ) ?? null;
1438 const oauthBusy = ccBusy || oauthPopup.busy;1475 const oauthBusy = ccBusy || oauthPopup.busy;
1439 const cimdConnecting = cimdBusy || oauthPopup.busy;1476 const cimdConnecting = cimdBusy || oauthPopup.busy;
1440 const dcrConnecting = dcrBusy || oauthPopup.busy;1477 const dcrConnecting = dcrBusy || oauthPopup.busy;
@@ -1898,6 +1935,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 457 unmodified lines
1898 payload: {1935 payload: {
1899 owner: args.owner,1936 owner: args.owner,
1900 slug: args.slug,1937 slug: args.slug,
1938 issuer: args.issuer ?? null,
1901 registrationEndpoint: args.registrationEndpoint,1939 registrationEndpoint: args.registrationEndpoint,
1902 authorizationUrl: args.authorizationUrl,1940 authorizationUrl: args.authorizationUrl,
1903 tokenUrl: args.tokenUrl,1941 tokenUrl: args.tokenUrl,
@@ -1945,9 +1983,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 41 unmodified lines
1945 resourceFallback: method.oauth?.discoveryUrl,1983 resourceFallback: method.oauth?.discoveryUrl,
1946 owner: dcrOwner,1984 owner: dcrOwner,
1947 integrationName,1985 integrationName,
1948 existingSlugs: [...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>1986 // DCR slugs are server-keyed (Part A): the connect path no longer depends
1949 String(app.slug),1987 // on the picker's app list, so it need not be threaded here.
1950 ),
1951 declaredScopes: method.oauth?.scopes,1988 declaredScopes: method.oauth?.scopes,
1952 redirectUri: oauthCallbackUrl(),1989 redirectUri: oauthCallbackUrl(),
1953 integration,1990 integration,
@@ -2014,11 +2051,18 @@ function AddAccountModalView(props: AddAccountModalProps) { · 60 unmodified lines
2014 <div className="px-5 py-5">2051 <div className="px-5 py-5">
2015 <OAuthClientForm2052 <OAuthClientForm
2016 integrationName={integrationName}2053 integrationName={integrationName}
2017 existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>2054 existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}
2018 String(app.slug),
2019 )}
2020 fixedSlug={editingClient.slug}2055 fixedSlug={editingClient.slug}
2021 fixedOwner={editingClient.owner}2056 fixedOwner={editingClient.owner}
2057 // Preserve the app's already-recorded origin verbatim (only a
2058 // "manual" origin carries a recorded integration; DCR apps
2059 // aren't edited through this form). Editing must never
2060 // re-derive this from the current dialog's integration.
2061 intentIntegration={
2062 editingClient.origin.kind === "manual"
2063 ? (editingClient.origin.integration ?? null)
2064 : null
2065 }
2022 prefill={{2066 prefill={{
2023 authorizationUrl: editingClient.authorizationUrl,2067 authorizationUrl: editingClient.authorizationUrl,
2024 tokenUrl: editingClient.tokenUrl,2068 tokenUrl: editingClient.tokenUrl,
@@ -2043,9 +2087,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 18 unmodified lines
2043 <div className="px-5 py-5">2087 <div className="px-5 py-5">
2044 <OAuthClientForm2088 <OAuthClientForm
2045 integrationName={integrationName}2089 integrationName={integrationName}
2046 existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) =>2090 integrationSlug={integration}
2047 String(app.slug),2091 existingSlugs={clientSummaries.map((c: OAuthClientSummary) => String(c.slug))}
2048 )}
2049 autoRegisterRejectedReason={dcrFallbackMessage}2092 autoRegisterRejectedReason={dcrFallbackMessage}
2050 prefill={{2093 prefill={{
2051 authorizationUrl:2094 authorizationUrl:
@@ -2063,6 +2106,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 11 unmodified lines
2063 null,2106 null,
2064 scopes: method.oauth?.scopes,2107 scopes: method.oauth?.scopes,
2065 discoveredScopes: oauthFallbackProbe?.scopesSupported,2108 discoveredScopes: oauthFallbackProbe?.scopesSupported,
2109 issuer: oauthFallbackProbe?.issuer ?? null,
2066 registrationEndpoint:2110 registrationEndpoint:
2067 method.oauth?.registrationEndpoint ??2111 method.oauth?.registrationEndpoint ??
2068 oauthFallbackProbe?.registrationEndpoint ??2112 oauthFallbackProbe?.registrationEndpoint ??
@@ -2324,6 +2368,107 @@ function AddAccountModalView(props: AddAccountModalProps) { · 255 unmodified lines
2324 </Button>2368 </Button>
2325 </RadioGroup>2369 </RadioGroup>
2326 )}2370 )}
2371
2372 {/* Tier 2 — apps that only match by root domain (a
2373 near-miss, e.g. the same provider's MCP app for a REST
2374 integration). Visually separated and subdued so they are
2375 never mistaken for the integration's own app, but still
2376 selectable as an escape hatch. Shown alongside tier 1, not
2377 mixed into it. */}
2378 {oauthNearApps.length > 0 && (
2379 <div className="space-y-2 border-t border-border/60 pt-3">
2380 <p className="text-xs font-medium text-muted-foreground">
2381 Other apps on this provider
2382 </p>
2383 <p className="text-xs text-muted-foreground">
2384 These share {integrationName}&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 )}
2327 </div>2472 </div>
2328 )2473 )
2329 ) : (2474 ) : (
packages/react/src/components/oauth-client-form.test.ts+29−1
@@ -1,6 +1,11 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { IntegrationSlug } from "@executor-js/sdk/shared";
2 3
3import { canSubmitOAuthClientForm, registrationScopes } from "./oauth-client-form";4import {
5 canSubmitOAuthClientForm,
6 registrationScopes,
7 resolveOriginIntegration,
8} from "./oauth-client-form";
4 9
5const validBase = {10const validBase = {
6 submitting: false,11 submitting: false,
@@ -36,6 +41,29 @@ describe("registrationScopes", () => { · 29 unmodified lines
36 });41 });
37});42});
38 43
44describe("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
39describe("canSubmitOAuthClientForm", () => {67describe("canSubmitOAuthClientForm", () => {
40 it("allows authorization-code public clients with no client secret", () => {68 it("allows authorization-code public clients with no client secret", () => {
41 expect(69 expect(
packages/react/src/components/oauth-client-form.tsx+46−4
@@ -1,7 +1,12 @@
1import { useMemo, useState } from "react";1import { useMemo, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";3import * as Exit from "effect/Exit";
4import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared";4import {
5 OAuthClientSlug,
6 type IntegrationSlug,
7 type OAuthGrant,
8 type Owner,
9} from "@executor-js/sdk/shared";
5import { toast } from "sonner";10import { toast } from "sonner";
6 11
7import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";12import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";
@@ -9,7 +14,7 @@ import { ownerLabelForHost } from "../api/owner-display"; · 1 unmodified line
9import { trackEvent } from "../api/analytics";14import { trackEvent } from "../api/analytics";
10import { useOrganizationId } from "../api/organization-context";15import { useOrganizationId } from "../api/organization-context";
11import { oauthClientWriteKeys } from "../api/reactivity-keys";16import { oauthClientWriteKeys } from "../api/reactivity-keys";
12import { uniqueClientSlug } from "../plugins/use-effective-oauth-client";17import { optimisticDcrClientSlug, uniqueClientSlug } from "../plugins/use-effective-oauth-client";
13import { oauthCallbackUrl } from "../plugins/oauth-sign-in";18import { oauthCallbackUrl } from "../plugins/oauth-sign-in";
14import {19import {
15 ConnectionOwnerDropdown,20 ConnectionOwnerDropdown,
@@ -36,6 +41,7 @@ import { RadioGroup, RadioGroupItem } from "./radio-group"; · 20 unmodified lines
36// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
37 42
38export interface OAuthClientFormPrefill {43export interface OAuthClientFormPrefill {
44 readonly issuer?: string | null;
39 readonly authorizationUrl?: string;45 readonly authorizationUrl?: string;
40 readonly tokenUrl?: string;46 readonly tokenUrl?: string;
41 readonly resource?: string | null;47 readonly resource?: string | null;
@@ -70,6 +76,19 @@ export const registrationScopes = ( · 28 unmodified lines
70 discoveredScopes: readonly string[],76 discoveredScopes: readonly string[],
71): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes);77): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes);
72 78
79/** The `originIntegration` to send with a `createClient` payload. When the
80 * caller passes an explicit `intentIntegration` (edit flow: the app's
81 * preserved recorded intent, which may legitimately be `null`), it wins
82 * verbatim — editing must never silently overwrite a stamp. Otherwise this is
83 * a fresh registration from an integration's dialog, which stamps the current
84 * integration as recorded intent (or `null` outside any integration
85 * context). */
86export const resolveOriginIntegration = (
87 intentIntegration: IntegrationSlug | null | undefined,
88 integrationSlug: IntegrationSlug | undefined,
89): IntegrationSlug | null =>
90 intentIntegration !== undefined ? intentIntegration : (integrationSlug ?? null);
91
73export const canSubmitOAuthClientForm = (input: {92export const canSubmitOAuthClientForm = (input: {
74 readonly submitting: boolean;93 readonly submitting: boolean;
75 readonly name: string;94 readonly name: string;
@@ -89,6 +108,19 @@ export const canSubmitOAuthClientForm = (input: { · 13 unmodified lines
89export function OAuthClientForm(props: {108export function OAuthClientForm(props: {
90 /** Human label for the integration this app backs (used in toasts + default name). */109 /** Human label for the integration this app backs (used in toasts + default name). */
91 readonly integrationName: string;110 readonly integrationName: string;
111 /** Slug of the integration whose dialog this form is registering from, when
112 * known. Stamped onto the created MANUAL app as recorded intent so the picker
113 * matches it to this integration exactly (not by root-domain guess). Omitted
114 * when editing (an existing app's origin is fixed) or when there is no single
115 * integration context. */
116 readonly integrationSlug?: IntegrationSlug;
117 /** Explicit recorded-intent stamp to send verbatim, overriding the default
118 * derivation from `integrationSlug`. Set this when editing an existing app
119 * so its ALREADY-recorded origin (which may be `null`) is preserved rather
120 * than re-derived from the current dialog's integration context. Omit for a
121 * fresh registration, where the default (stamp `integrationSlug`, or `null`
122 * outside an integration context) is correct. */
123 readonly intentIntegration?: IntegrationSlug | null;
92 /** Existing client slugs, so the generated slug stays unique across apps. */124 /** Existing client slugs, so the generated slug stays unique across apps. */
93 readonly existingSlugs: readonly string[];125 readonly existingSlugs: readonly string[];
94 /** Endpoints/scopes declared by the integration's OAuth method. */126 /** Endpoints/scopes declared by the integration's OAuth method. */
@@ -112,6 +144,8 @@ export function OAuthClientForm(props: { · 17 unmodified lines
112}) {144}) {
113 const {145 const {
114 integrationName,146 integrationName,
147 integrationSlug,
148 intentIntegration,
115 existingSlugs,149 existingSlugs,
116 prefill,150 prefill,
117 fixedSlug,151 fixedSlug,
@@ -147,6 +181,7 @@ export function OAuthClientForm(props: { · 29 unmodified lines
147 const [clientId, setClientId] = useState(prefill?.clientId ?? "");181 const [clientId, setClientId] = useState(prefill?.clientId ?? "");
148 const [clientSecret, setClientSecret] = useState("");182 const [clientSecret, setClientSecret] = useState("");
149 const [issuerUrl, setIssuerUrl] = useState("");183 const [issuerUrl, setIssuerUrl] = useState("");
184 const [discoveredIssuer, setDiscoveredIssuer] = useState<string | null>(prefill?.issuer ?? null);
150 const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");185 const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");
151 const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");186 const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");
152 const [resource, setResource] = useState(prefill?.resource ?? null);187 const [resource, setResource] = useState(prefill?.resource ?? null);
@@ -226,6 +261,7 @@ export function OAuthClientForm(props: { · 73 unmodified lines
226 return;261 return;
227 }262 }
228 const result = exit.value;263 const result = exit.value;
264 setDiscoveredIssuer(result.issuer ?? null);
229 setAuthorizationUrl(result.authorizationUrl);265 setAuthorizationUrl(result.authorizationUrl);
230 setTokenUrl(result.tokenUrl);266 setTokenUrl(result.tokenUrl);
231 setResource(result.resource ?? null);267 setResource(result.resource ?? null);
@@ -247,11 +283,13 @@ export function OAuthClientForm(props: { · 15 unmodified lines
247 const handleRegisterDynamic = async () => {283 const handleRegisterDynamic = async () => {
248 if (!canRegisterDynamic || registering) return;284 if (!canRegisterDynamic || registering) return;
249 setRegistering(true);285 setRegistering(true);
250 const slug = fixedSlug ?? uniqueClientSlug(name, existingSlugs);286 const slug =
287 fixedSlug ?? optimisticDcrClientSlug(discoveredIssuer ?? registrationEndpoint.trim());
251 const exit = await doRegisterDynamic({288 const exit = await doRegisterDynamic({
252 payload: {289 payload: {
253 owner,290 owner,
254 slug,291 slug,
292 issuer: discoveredIssuer,
255 registrationEndpoint: registrationEndpoint.trim(),293 registrationEndpoint: registrationEndpoint.trim(),
256 authorizationUrl: authorizationUrl.trim(),294 authorizationUrl: authorizationUrl.trim(),
257 tokenUrl: tokenUrl.trim(),295 tokenUrl: tokenUrl.trim(),
@@ -273,7 +311,7 @@ export function OAuthClientForm(props: { · 15 unmodified lines
273 }311 }
274 trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true });312 trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true });
275 toast.success(`Registered ${integrationName} OAuth app`);313 toast.success(`Registered ${integrationName} OAuth app`);
276 onCreated({ owner, slug });314 onCreated({ owner, slug: exit.value.client });
277 };315 };
278 316
279 const handleSubmit = async () => {317 const handleSubmit = async () => {
@@ -290,6 +328,10 @@ export function OAuthClientForm(props: { · 10 unmodified lines
290 clientId: clientId.trim(),328 clientId: clientId.trim(),
291 clientSecret: clientSecret.trim(),329 clientSecret: clientSecret.trim(),
292 resource,330 resource,
331 // Editing preserves the app's already-recorded origin (via
332 // `intentIntegration`, passed verbatim by the caller); a fresh
333 // registration from an integration's dialog stamps recorded intent.
334 originIntegration: resolveOriginIntegration(intentIntegration, integrationSlug),
293 },335 },
294 reactivityKeys: oauthClientWriteKeys,336 reactivityKeys: oauthClientWriteKeys,
295 });337 });
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.
21import { describe, expect, it } from "@effect/vitest";
22import { IntegrationSlug, OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";
23import type { OAuthClientOrigin } from "@executor-js/sdk/shared";
24
25import {
26 selectClientsForEndpoints,
27 selectDcrClientsForIntegration,
28 type OAuthClientOption,
29} from "./use-effective-oauth-client";
30
31const 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
49const MCP_INTEGRATION = IntegrationSlug.make("cloudflare_mcp");
50const 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).
55const 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.
62const 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
69describe("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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";2import { IntegrationSlug, OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";
3import type { OAuthClientOrigin } from "@executor-js/sdk/shared";
3 4
4import {5import {
6 optimisticDcrClientSlug,
5 selectClientsForEndpoints,7 selectClientsForEndpoints,
8 selectDcrClientsForIntegration,
6 uniqueClientSlug,9 uniqueClientSlug,
7 type OAuthClientOption,10 type OAuthClientOption,
8} from "./use-effective-oauth-client";11} from "./use-effective-oauth-client";
@@ -13,6 +16,7 @@ const app = ( · 4 unmodified lines
13 readonly owner?: Owner;16 readonly owner?: Owner;
14 readonly authorizationUrl: string;17 readonly authorizationUrl: string;
15 readonly tokenUrl: string;18 readonly tokenUrl: string;
19 readonly origin?: OAuthClientOrigin;
16 },20 },
17): OAuthClientOption => ({21): OAuthClientOption => ({
18 owner: opts.owner ?? "user",22 owner: opts.owner ?? "user",
@@ -21,6 +25,7 @@ const app = ( · 2 unmodified lines
21 authorizationUrl: opts.authorizationUrl,25 authorizationUrl: opts.authorizationUrl,
22 tokenUrl: opts.tokenUrl,26 tokenUrl: opts.tokenUrl,
23 clientId: "client-id",27 clientId: "client-id",
28 origin: opts.origin ?? { kind: "manual" },
24});29});
25 30
26const google = app("google-app", {31const google = app("google-app", {
@@ -89,6 +94,27 @@ describe("selectClientsForEndpoints", () => { · 62 unmodified lines
89 ]);94 ]);
90 });95 });
91 96
97 it("does not tier-1 match an unparseable app URL against a scheme-less declared endpoint", () => {
98 // Regression: `hostOf` returns undefined for URLs `new URL()` can't parse.
99 // An integration whose manifest declares a scheme-less authorization URL
100 // ("oauth.cloudflare.com", no validation on manifest Schema.String) and an
101 // unrelated app whose stored URL is also unparseable would both yield
102 // `undefined` hosts, so a naive `appAuthorizationHost === wantedAuthorizationHost`
103 // is `undefined === undefined` → a false tier-1 (default-picked) match. The
104 // guard must require BOTH hosts defined, so this app lands in `unmatched`.
105 const unparseable = app("mystery-app", {
106 authorizationUrl: "not a url",
107 tokenUrl: "not a url",
108 });
109 const result = selectClientsForEndpoints([unparseable], {
110 authorizationUrl: "oauth.cloudflare.com",
111 });
112 expect(result.matched).toEqual([]);
113 expect(result.nearMatches).toEqual([]);
114 expect(result.endpointMatched).toBe(false);
115 expect(result.unmatched.map((a: OAuthClientOption) => String(a.slug))).toEqual(["mystery-app"]);
116 });
117
92 it("treats every app as usable when no endpoint is declared", () => {118 it("treats every app as usable when no endpoint is declared", () => {
93 const result = selectClientsForEndpoints([google, spotify], {});119 const result = selectClientsForEndpoints([google, spotify], {});
94 expect(result.endpointMatched).toBe(true);120 expect(result.endpointMatched).toBe(true);
@@ -133,6 +159,161 @@ describe("selectClientsForEndpoints", () => { · 38 unmodified lines
133 });159 });
134 expect(result.matched.map((a: OAuthClientOption) => a.owner)).toEqual(["user", "org"]);160 expect(result.matched.map((a: OAuthClientOption) => a.owner)).toEqual(["user", "org"]);
135 });161 });
162
163 it("excludes DCR clients from the picker entirely (they are plumbing, not apps)", () => {
164 // A DCR client minted against the integration's exact endpoint STILL must not
165 // appear in the picker — it is reused automatically, never user-picked.
166 const dcr = app("dcr-oauth2-googleapis-com", {
167 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
168 tokenUrl: "https://oauth2.googleapis.com/token",
169 origin: { kind: "dynamic_client_registration", integration: null },
170 });
171 const result = selectClientsForEndpoints([dcr, google], {
172 tokenUrl: "https://oauth2.googleapis.com/token",
173 });
174 // Only the manual google-app survives into any tier; the DCR client is gone.
175 expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual(["google-app"]);
176 expect(result.nearMatches).toEqual([]);
177 expect(result.unmatched).toEqual([]);
178 });
179
180 it("origin-integration (recorded intent) is a tier-1 match, beating a root-domain near-miss", () => {
181 const integration = IntegrationSlug.make("cloudflare_api");
182 // Registered from the Cloudflare API dialog: exact intent, even though its
183 // OAuth host (dash.cloudflare.com) is not the declared api.cloudflare.com.
184 const stamped = app("my-cloudflare-app", {
185 authorizationUrl: "https://dash.cloudflare.com/oauth2/auth",
186 tokenUrl: "https://dash.cloudflare.com/oauth2/token",
187 origin: { kind: "manual", integration },
188 });
189 // Same root domain but different host and no intent stamp: a near-miss.
190 const nearMiss = app("cloudflare-mcp-app", {
191 authorizationUrl: "https://mcp.cloudflare.com/authorize",
192 tokenUrl: "https://mcp.cloudflare.com/token",
193 });
194 const result = selectClientsForEndpoints([nearMiss, stamped], {
195 tokenUrl: "https://api.cloudflare.com/client/v4/token",
196 authorizationUrl: "https://api.cloudflare.com/client/v4/authorize",
197 integration,
198 });
199 expect(result.endpointMatched).toBe(true);
200 // Intent wins tier 1; the root-domain near-miss lands in tier 2, never mixed in.
201 expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
202 "my-cloudflare-app",
203 ]);
204 expect(result.nearMatches.map((a: OAuthClientOption) => String(a.slug))).toEqual([
205 "cloudflare-mcp-app",
206 ]);
207 expect(result.unmatched).toEqual([]);
208 });
209
210 it("a root-domain-only match lands in tier 2 (nearMatches), not tier 1", () => {
211 // The declared endpoint is api.cloudflare.com; the app is on mcp.cloudflare.com.
212 // Same registrable root (cloudflare.com), different host, no intent stamp: this
213 // is exactly the bug case — it must NOT be presented as a real match.
214 const nearMiss = app("cloudflare-mcp-app", {
215 authorizationUrl: "https://mcp.cloudflare.com/authorize",
216 tokenUrl: "https://mcp.cloudflare.com/token",
217 });
218 const result = selectClientsForEndpoints([nearMiss], {
219 tokenUrl: "https://api.cloudflare.com/client/v4/token",
220 authorizationUrl: "https://api.cloudflare.com/client/v4/authorize",
221 integration: IntegrationSlug.make("cloudflare_api"),
222 });
223 expect(result.endpointMatched).toBe(false);
224 expect(result.matched).toEqual([]);
225 expect(result.nearMatches.map((a: OAuthClientOption) => String(a.slug))).toEqual([
226 "cloudflare-mcp-app",
227 ]);
228 expect(result.unmatched).toEqual([]);
229 });
230
231 it("exact endpoint host is a tier-1 match even without an intent stamp", () => {
232 const exact = app("cloudflare-api-app", {
233 authorizationUrl: "https://api.cloudflare.com/client/v4/authorize",
234 tokenUrl: "https://api.cloudflare.com/client/v4/token",
235 });
236 const result = selectClientsForEndpoints([exact], {
237 tokenUrl: "https://api.cloudflare.com/client/v4/token",
238 integration: IntegrationSlug.make("cloudflare_api"),
239 });
240 expect(result.endpointMatched).toBe(true);
241 expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
242 "cloudflare-api-app",
243 ]);
244 expect(result.nearMatches).toEqual([]);
245 });
246
247 it("recorded intent matches even when the integration declares no endpoints (requireEndpointMatch)", () => {
248 const integration = IntegrationSlug.make("linear_mcp");
249 const stamped = app("linear-mcp-app", {
250 authorizationUrl: "https://linear.app/oauth/authorize",
251 tokenUrl: "https://api.linear.app/oauth/token",
252 origin: { kind: "manual", integration },
253 });
254 const unrelated = app("spotify-app-2", {
255 authorizationUrl: "https://accounts.spotify.com/authorize",
256 tokenUrl: "https://accounts.spotify.com/api/token",
257 });
258 const result = selectClientsForEndpoints([unrelated, stamped], {
259 requireEndpointMatch: true,
260 integration,
261 });
262 expect(result.endpointMatched).toBe(true);
263 expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
264 "linear-mcp-app",
265 ]);
266 expect(result.unmatched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
267 "spotify-app-2",
268 ]);
269 });
270});
271
272describe("selectDcrClientsForIntegration", () => {
273 const integration = IntegrationSlug.make("linear_mcp");
274 const dcrStamped = app("dcr-auth-linear-app", {
275 authorizationUrl: "https://linear.app/oauth/authorize",
276 tokenUrl: "https://api.linear.app/oauth/token",
277 origin: { kind: "dynamic_client_registration", integration },
278 });
279 const dcrOtherProvider = app("dcr-mcp-cloudflare-com", {
280 authorizationUrl: "https://mcp.cloudflare.com/authorize",
281 tokenUrl: "https://mcp.cloudflare.com/token",
282 origin: { kind: "dynamic_client_registration", integration: null },
283 });
284 const manualLinear = app("linear-manual", {
285 authorizationUrl: "https://linear.app/oauth/authorize",
286 tokenUrl: "https://api.linear.app/oauth/token",
287 origin: { kind: "manual", integration },
288 });
289
290 it("returns only the DCR clients stamped with this integration", () => {
291 const result = selectDcrClientsForIntegration([dcrStamped, dcrOtherProvider, manualLinear], {
292 integration,
293 });
294 expect(result.map((a: OAuthClientOption) => String(a.slug))).toEqual(["dcr-auth-linear-app"]);
295 });
296
297 it("surfaces legacy unstamped DCR clients by root domain of the declared endpoint", () => {
298 const legacyUnstamped = app("dcr-api-linear-app", {
299 authorizationUrl: "https://linear.app/oauth/authorize",
300 tokenUrl: "https://api.linear.app/oauth/token",
301 origin: { kind: "dynamic_client_registration", integration: null },
302 });
303 const result = selectDcrClientsForIntegration([legacyUnstamped, dcrOtherProvider], {
304 integration,
305 tokenUrl: "https://api.linear.app/oauth/token",
306 });
307 expect(result.map((a: OAuthClientOption) => String(a.slug))).toEqual(["dcr-api-linear-app"]);
308 });
309
310 it("never returns manual apps", () => {
311 const result = selectDcrClientsForIntegration([manualLinear], {
312 integration,
313 tokenUrl: "https://api.linear.app/oauth/token",
314 });
315 expect(result).toEqual([]);
316 });
136});317});
137 318
138describe("uniqueClientSlug", () => {319describe("uniqueClientSlug", () => {
@@ -144,3 +325,14 @@ describe("uniqueClientSlug", () => { · 5 unmodified lines
144 );325 );
145 });326 });
146});327});
328
329describe("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 @@
1import { useMemo } from "react";
1import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";4import {
5 OAuthClientSlug,
6 type IntegrationSlug,
7 type OAuthClientOrigin,
8 type Owner,
9} from "@executor-js/sdk/shared";
4import { getDomain } from "tldts";10import { getDomain } from "tldts";
5 11
6import { oauthClientsOptimisticAtom } from "../api/atoms";12import { oauthClientsOptimisticAtom } from "../api/atoms";
@@ -9,12 +15,17 @@ import { oauthClientsOptimisticAtom } from "../api/atoms"; · 2 unmodified lines
9// OAuth client (registered app) selection for an integration's connect flow.15// OAuth client (registered app) selection for an integration's connect flow.
10//16//
11// An owner can register MANY apps; each is a distinct owner-scoped `oauth_client`17// An owner can register MANY apps; each is a distinct owner-scoped `oauth_client`
12// row with its own slug. The connect flow lists the apps usable for an18// row with its own slug. The connect flow lists the MANUAL apps usable for an
13// integration (owner-visible clients whose OAuth endpoints share a registrable19// integration and lets the user pick one or register a new one. Matching is by
14// root domain with the integration's declared endpoints when known) and lets the20// recorded intent first (an app registered from this integration's dialog, or
15// user pick one or register a new one. User-owned apps are listed before21// whose OAuth endpoint HOST exactly matches the integration's declared host),
16// workspace ones. When NOTHING matches a declared endpoint, the picker shows an22// then by registrable root domain as a subdued near-miss tier. User-owned apps
17// empty state + a "register an app" CTA rather than unrelated providers' apps.23// are listed before workspace ones.
24//
25// DCR (dynamic client registration) clients are plumbing, not apps: they are
26// minted automatically and reused per authorization server, so they NEVER appear
27// in this picker. They stay visible for management elsewhere (the modal's
28// "Auto-registered clients" surface), keyed off `origin.kind`.
18// ---------------------------------------------------------------------------29// ---------------------------------------------------------------------------
19 30
20export interface OAuthClientOption {31export interface OAuthClientOption {
@@ -24,8 +35,18 @@ export interface OAuthClientOption { · 3 unmodified lines
24 readonly authorizationUrl: string;35 readonly authorizationUrl: string;
25 readonly tokenUrl: string;36 readonly tokenUrl: string;
26 readonly clientId: string;37 readonly clientId: string;
38 /** How the app came to exist. `manual` apps are pickable (with `integration`
39 * recording which dialog registered them); `dynamic_client_registration`
40 * apps are excluded from the picker entirely. */
41 readonly origin: OAuthClientOrigin;
27}42}
28 43
44/** True for the auto-minted DCR clients that must never surface in the app
45 * picker. Legacy null-origin rows are already classified by the backend's
46 * `parseOAuthClientOrigin` heuristic, so this reads the parsed kind directly. */
47export const isDcrClient = (app: OAuthClientOption): boolean =>
48 app.origin.kind === "dynamic_client_registration";
49
29const hostOf = (url: string): string | undefined => {50const hostOf = (url: string): string | undefined => {
30 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"51 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
31 try {52 try {
@@ -46,35 +67,54 @@ const getRootDomain = (url: string): string | undefined => { · 14 unmodified lines
46};67};
47 68
48export interface UseOAuthClientsResult {69export interface UseOAuthClientsResult {
49 /** Apps usable for this integration, user-owned first. When an endpoint was70 /** Tier 1 — apps that match this integration by RECORDED INTENT (registered
50 * declared but nothing matched, this is EMPTY (the unmatched apps move to71 * from its dialog) or EXACT endpoint host, user-owned first. These are the
51 * `otherClients`). */72 * apps that belong to this integration; the picker defaults to the first one.
73 * When an endpoint was declared but nothing matched exactly, this is EMPTY. */
52 readonly clients: readonly OAuthClientOption[];74 readonly clients: readonly OAuthClientOption[];
53 /** Unmatched owner-visible apps — surfaced only as an opt-in escape hatch75 /** Tier 2 — apps that only match by registrable root domain (a near-miss: same
54 * ("use a different registered app") when no app matched the declared76 * provider family, different exact host / not stamped to this integration).
55 * endpoint. Empty when `endpointMatched` is true. */77 * Surfaced in a visually separate, subdued "Other apps on this provider"
78 * section — NEVER silently mixed into `clients`. Present whenever there are
79 * such near-misses, even when tier 1 is non-empty. */
80 readonly nearMatches: readonly OAuthClientOption[];
81 /** Unrelated apps (different root domain), kept only as the opt-in escape hatch
82 * ("use a different registered app") when NOTHING matched the declared
83 * endpoint at all. Empty once anything in tier 1 or tier 2 matched. */
56 readonly otherClients: readonly OAuthClientOption[];84 readonly otherClients: readonly OAuthClientOption[];
57 /** True until the clients list has loaded at least once. */85 /** True until the clients list has loaded at least once. */
58 readonly loading: boolean;86 readonly loading: boolean;
59 /**87 /**
60 * Whether the returned `clients` are matched to the integration's declared88 * Whether an app is EXACT-matched (tier 1) to the integration.
61 * OAuth endpoints (by registrable root domain across authorize + token).
62 *89 *
63 * - `true` — either no endpoint filter was requested (the integration90 * - `true` — either no endpoint filter was requested (the integration
64 * declares no endpoints), or at least one registered app's authorize/token91 * declares no endpoints), or at least one app matched by recorded intent or
65 * root domain matched. `clients` are the matched subset.92 * exact endpoint host. `clients` is that tier-1 subset.
66 * - `false` — the integration declared endpoint(s) but NO registered app93 * - `false` — the integration declared endpoint(s) but no app matched exactly.
67 * matched. `clients` is then EMPTY (so the UI shows an empty state + a94 * `clients` is EMPTY (the UI shows an empty state + a register CTA). Any
68 * register CTA rather than unrelated providers' apps), and the unmatched95 * root-domain near-misses are still offered via `nearMatches`; truly
69 * apps are surfaced separately in `otherClients` for the opt-in escape hatch.96 * unrelated apps via `otherClients`.
70 */97 */
71 readonly endpointMatched: boolean;98 readonly endpointMatched: boolean;
72 /** Convenience flag for the UI: a register-an-app CTA should be shown because99 /** Convenience flag for the UI: a register-an-app CTA should be shown because
73 * an endpoint was declared and nothing matched. Equals `!endpointMatched`100 * an endpoint was declared and nothing exact-matched. Equals `!endpointMatched`
74 * once loaded. */101 * once loaded. */
75 readonly displayRegisterCTA: boolean;102 readonly displayRegisterCTA: boolean;
76}103}
77 104
105/** Stable empty-list reference so the picker memo doesn't re-run while the
106 * optimistic clients atom is still loading (a fresh `[]` each render would
107 * invalidate the memo key). */
108const 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. */
115const hostEq = (a: string | undefined, b: string | undefined): boolean =>
116 a !== undefined && b !== undefined && a === b;
117
78/** Sort apps user-owned first (so the user's own apps surface before shared118/** Sort apps user-owned first (so the user's own apps surface before shared
79 * workspace apps). */119 * workspace apps). */
80const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>120const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>
@@ -83,23 +123,37 @@ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClient · 2 unmodified lines
83 );123 );
84 124
85/**125/**
86 * Pure matcher (no React/atoms) — split owner-visible apps into the ones that126 * Pure matcher (no React/atoms) — split owner-visible apps into three honest
87 * match the integration's declared OAuth endpoints and the ones that don't.127 * tiers for an integration's connect picker.
128 *
129 * FIRST, DCR clients are dropped entirely: they are auto-minted plumbing reused
130 * per authorization server, not apps a user picks. They stay manageable through
131 * a separate surface, never the picker.
88 *132 *
89 * Matching is by REGISTRABLE ROOT DOMAIN ("tld+1"). When the integration133 * Then the remaining MANUAL apps are graded:
90 * declares a token endpoint, an app must match by token endpoint root; the token134 * - `matched` (tier 1): the app was registered from THIS integration's dialog
91 * endpoint is what the SDK will call during code exchange/refresh and avoids135 * (`origin.integration === integration`, recorded intent), OR its OAuth
92 * authorize-root coincidences. When only an authorization endpoint is declared,136 * endpoint HOST exactly matches the integration's declared host (token host
93 * the authorize root is used as the fallback compatibility signal. Unrelated137 * when a token endpoint is declared, else the authorize host). This is the
94 * providers (different root) never match.138 * subset that truly belongs to the integration.
139 * - `nearMatches` (tier 2): the app shares the integration's REGISTRABLE ROOT
140 * DOMAIN ("tld+1") but is not a tier-1 match — a same-provider near-miss (e.g.
141 * an app on `mcp.cloudflare.com` for a REST integration on `api.cloudflare.com`).
142 * Silently promoting these into tier 1 is the bug this change fixes.
143 * - `unmatched`: unrelated provider (different root), offered only as the escape
144 * hatch when nothing matched at all.
95 *145 *
96 * When the integration declares no endpoints, every app is "matched" (no filter).146 * When the integration declares no endpoints (and no match is required), every
147 * manual app is tier 1 (no filter to grade against).
97 */148 */
98export function selectClientsForEndpoints(149export function selectClientsForEndpoints(
99 all: readonly OAuthClientOption[],150 all: readonly OAuthClientOption[],
100 endpoints: {151 endpoints: {
101 readonly tokenUrl?: string;152 readonly tokenUrl?: string;
102 readonly authorizationUrl?: string;153 readonly authorizationUrl?: string;
154 /** The integration whose picker this is. A manual app stamped with this
155 * integration (recorded intent) is a tier-1 match regardless of host. */
156 readonly integration?: IntegrationSlug;
103 /** When set, an integration that targets a SPECIFIC server (MCP, whose157 /** When set, an integration that targets a SPECIFIC server (MCP, whose
104 * endpoints are discovered at connect) must match by endpoint — absent158 * endpoints are discovered at connect) must match by endpoint — absent
105 * endpoints mean NO match (show the register CTA), never "every app159 * endpoints mean NO match (show the register CTA), never "every app
@@ -108,36 +162,81 @@ export function selectClientsForEndpoints( · 2 unmodified lines
108 },162 },
109): {163): {
110 readonly matched: readonly OAuthClientOption[];164 readonly matched: readonly OAuthClientOption[];
165 readonly nearMatches: readonly OAuthClientOption[];
111 readonly unmatched: readonly OAuthClientOption[];166 readonly unmatched: readonly OAuthClientOption[];
112 readonly endpointMatched: boolean;167 readonly endpointMatched: boolean;
113} {168} {
169 // DCR clients are plumbing, never picker options.
170 const manual = all.filter((app) => !isDcrClient(app));
171
172 const intent = endpoints.integration;
173 const matchesIntent = (app: OAuthClientOption): boolean =>
174 intent != null &&
175 app.origin.kind === "manual" &&
176 app.origin.integration != null &&
177 app.origin.integration === intent;
178
179 const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined;
180 const wantedAuthorizationHost = endpoints.authorizationUrl
181 ? hostOf(endpoints.authorizationUrl)
182 : undefined;
114 const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined;183 const wantedTokenRoot = endpoints.tokenUrl ? getRootDomain(endpoints.tokenUrl) : undefined;
115 const wantedAuthorizationRoot = endpoints.authorizationUrl184 const wantedAuthorizationRoot = endpoints.authorizationUrl
116 ? getRootDomain(endpoints.authorizationUrl)185 ? getRootDomain(endpoints.authorizationUrl)
117 : undefined;186 : undefined;
187
118 // No declared endpoints. A server-targeting integration must NOT match every188 // No declared endpoints. A server-targeting integration must NOT match every
119 // app (it would auto-select an unrelated provider); surface the register CTA189 // app (it would auto-select an unrelated provider); surface the register CTA
120 // instead. Otherwise (no endpoint filter at all) every app is usable.190 // instead. Otherwise (no endpoint filter at all) every manual app is usable.
121 if (!wantedTokenRoot && !wantedAuthorizationRoot) {191 if (!wantedTokenRoot && !wantedAuthorizationRoot) {
122 if (endpoints.requireEndpointMatch) {192 if (endpoints.requireEndpointMatch) {
123 return { matched: [], unmatched: sortUserFirst(all), endpointMatched: false };193 // Even with no declared endpoints, an app registered from THIS dialog is a
194 // real tier-1 match (the user built it here on purpose).
195 const matched = manual.filter(matchesIntent);
196 const rest = manual.filter((app) => !matchesIntent(app));
197 return {
198 matched: sortUserFirst(matched),
199 nearMatches: [],
200 unmatched: sortUserFirst(rest),
201 endpointMatched: matched.length > 0,
202 };
124 }203 }
125 return { matched: sortUserFirst(all), unmatched: [], endpointMatched: true };204 return {
205 matched: sortUserFirst(manual),
206 nearMatches: [],
207 unmatched: [],
208 endpointMatched: true,
209 };
126 }210 }
211
127 const matched: OAuthClientOption[] = [];212 const matched: OAuthClientOption[] = [];
213 const nearMatches: OAuthClientOption[] = [];
128 const unmatched: OAuthClientOption[] = [];214 const unmatched: OAuthClientOption[] = [];
129 for (const app of all) {215 for (const app of manual) {
216 const appTokenHost = hostOf(app.tokenUrl);
217 const appAuthorizationHost = hostOf(app.authorizationUrl);
130 const appTokenRoot = getRootDomain(app.tokenUrl);218 const appTokenRoot = getRootDomain(app.tokenUrl);
131 const appAuthorizationRoot = getRootDomain(app.authorizationUrl);219 const appAuthorizationRoot = getRootDomain(app.authorizationUrl);
132 const fits = wantedTokenRoot220 // Exact HOST match against the declared endpoint (token host first, since the
133 ? appTokenRoot === wantedTokenRoot221 // token endpoint is what the SDK actually calls; authorize host as fallback
134 : appAuthorizationRoot === wantedAuthorizationRoot ||222 // when no token endpoint was declared).
135 appTokenRoot === wantedAuthorizationRoot;223 const exactHostMatch = wantedTokenHost
136 if (fits) matched.push(app);224 ? hostEq(appTokenHost, wantedTokenHost)
225 : hostEq(appAuthorizationHost, wantedAuthorizationHost) ||
226 hostEq(appTokenHost, wantedAuthorizationHost);
227 // Same registrable root domain (the old, looser heuristic) — now only a
228 // tier-2 signal.
229 const rootMatch = wantedTokenRoot
230 ? hostEq(appTokenRoot, wantedTokenRoot)
231 : hostEq(appAuthorizationRoot, wantedAuthorizationRoot) ||
232 hostEq(appTokenRoot, wantedAuthorizationRoot);
233 if (matchesIntent(app) || exactHostMatch) matched.push(app);
234 else if (rootMatch) nearMatches.push(app);
137 else unmatched.push(app);235 else unmatched.push(app);
138 }236 }
139 return {237 return {
140 matched: sortUserFirst(matched),238 matched: sortUserFirst(matched),
239 nearMatches: sortUserFirst(nearMatches),
141 unmatched: sortUserFirst(unmatched),240 unmatched: sortUserFirst(unmatched),
142 endpointMatched: matched.length > 0,241 endpointMatched: matched.length > 0,
143 };242 };
@@ -146,6 +245,7 @@ export function selectClientsForEndpoints( · 2 unmodified lines
146export function useOAuthClientsForIntegration(opts: {245export function useOAuthClientsForIntegration(opts: {
147 readonly tokenUrl?: string;246 readonly tokenUrl?: string;
148 readonly authorizationUrl?: string;247 readonly authorizationUrl?: string;
248 readonly integration?: IntegrationSlug;
149 readonly requireEndpointMatch?: boolean;249 readonly requireEndpointMatch?: boolean;
150}): UseOAuthClientsResult {250}): UseOAuthClientsResult {
151 // Read the optimistic list so a just-registered/edited/removed app paints251 // Read the optimistic list so a just-registered/edited/removed app paints
@@ -153,9 +253,29 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line
153 // lands. The modal's management menu reads the same optimistic atom, so the253 // lands. The modal's management menu reads the same optimistic atom, so the
154 // picker rows and their actions stay consistent.254 // picker rows and their actions stay consistent.
155 const clientsResult = useAtomValue(oauthClientsOptimisticAtom);255 const clientsResult = useAtomValue(oauthClientsOptimisticAtom);
156 if (!AsyncResult.isSuccess(clientsResult)) {256 const loaded = AsyncResult.isSuccess(clientsResult);
257 const all = loaded ? (clientsResult.value as readonly OAuthClientOption[]) : EMPTY_CLIENTS;
258
259 // Memoize the grade: `selectClientsForEndpoints` parses every client's URLs
260 // with tldts, and the modal passes a FRESH inline options object each render,
261 // so without this every keystroke would re-grade all clients. Key on the
262 // primitive inputs plus the `all` array reference (a new optimistic list is a
263 // new array), NOT on `opts` identity.
264 const selection = useMemo(
265 () =>
266 selectClientsForEndpoints(all, {
267 tokenUrl: opts.tokenUrl,
268 authorizationUrl: opts.authorizationUrl,
269 integration: opts.integration,
270 requireEndpointMatch: opts.requireEndpointMatch,
271 }),
272 [all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.requireEndpointMatch],
273 );
274
275 if (!loaded) {
157 return {276 return {
158 clients: [],277 clients: [],
278 nearMatches: [],
159 otherClients: [],279 otherClients: [],
160 loading: true,280 loading: true,
161 endpointMatched: true,281 endpointMatched: true,
@@ -163,19 +283,16 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line
163 };283 };
164 }284 }
165 285
166 const all = clientsResult.value as readonly OAuthClientOption[];286 const { matched, nearMatches, unmatched, endpointMatched } = selection;
167 const { matched, unmatched, endpointMatched } = selectClientsForEndpoints(all, {287 // EXPLICIT outcome: `clients` is the tier-1 (exact/intent) subset the picker
168 tokenUrl: opts.tokenUrl,288 // defaults into. `nearMatches` (root-domain-only) is always surfaced, but in a
169 authorizationUrl: opts.authorizationUrl,289 // separate subdued section so it is never mistaken for a real match. When an
170 requireEndpointMatch: opts.requireEndpointMatch,290 // endpoint was declared but nothing exact-matched, `clients` is EMPTY and a
171 });291 // register CTA is flagged; the unrelated `unmatched` apps stay in `otherClients`
172 // EXPLICIT outcome: when at least one app matched (or no endpoint was292 // as the last-resort escape hatch.
173 // declared) we present the matched subset. When an endpoint was declared but
174 // nothing matched, `clients` is EMPTY — the unmatched apps move to
175 // `otherClients` for an opt-in escape hatch — and we flag a register CTA so
176 // the UI offers "register an app" instead of surfacing unrelated providers.
177 return {293 return {
178 clients: endpointMatched ? matched : [],294 clients: endpointMatched ? matched : [],
295 nearMatches,
179 otherClients: endpointMatched ? [] : unmatched,296 otherClients: endpointMatched ? [] : unmatched,
180 loading: false,297 loading: false,
181 endpointMatched,298 endpointMatched,
@@ -183,6 +300,49 @@ export function useOAuthClientsForIntegration(opts: { · 1 unmodified line
183 };300 };
184}301}
185 302
303/**
304 * The auto-registered (DCR) clients relevant to an integration, for the
305 * management surface (list + delete). These are hidden from the app picker but
306 * must stay visible SOMEWHERE. A DCR client is relevant when it records this
307 * integration as its origin (Part A stamps `origin.integration`), or — for
308 * legacy clients minted before the stamp — when its endpoints share the
309 * integration's declared/discovered root domain.
310 */
311export 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
186const slugifyName = (name: string): string =>346const slugifyName = (name: string): string =>
187 name347 name
188 .toLowerCase()348 .toLowerCase()
@@ -199,6 +359,24 @@ export function uniqueClientSlug(name: string, existing: readonly string[]): OAu · 10 unmodified lines
199 return OAuthClientSlug.make(`${base}-${suffix}`);359 return OAuthClientSlug.make(`${base}-${suffix}`);
200}360}
201 361
362/**
363 * Optimistic (client-side) DCR client slug derived from the authorization
364 * server host, for immediate/placeholder display only.
365 *
366 * The AUTHORITATIVE slug is computed server-side by `dcrClientSlug` in
367 * `packages/core/sdk/src/oauth-service.ts`, which is resource-aware (an AS
368 * serving multiple RFC 8707 resources gets distinct, hash-suffixed slugs). This
369 * host-only form deliberately ignores resource: it is a best-effort placeholder
370 * the UI shows before the server responds, and the server recomputes and
371 * persists the real slug on registration. Do NOT rely on it matching the stored
372 * slug for a multi-resource server.
373 */
374export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSlug {
375 const host = hostOf(issuerOrEndpoint);
376 const base = host === undefined ? "" : slugifyName(host);
377 return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`);
378}
379
202/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */380/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */
203export function clientDisplayName(slug: string): string {381export function clientDisplayName(slug: string): string {
204 const text = slug.replace(/[-_]/g, " ").trim();382 const text = slug.replace(/[-_]/g, " ").trim();
patches/agents@0.17.3.patch+138−37
@@ -1,8 +1,33 @@
1diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts
2index 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,
1diff --git a/dist/mcp/index.js b/dist/mcp/index.js21diff --git a/dist/mcp/index.js b/dist/mcp/index.js
2index 1edcf0c8..e65cd346 10064422index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..0ba4f5bff8e6a968aa18c609952e576cedf13d8f 100644
3--- a/dist/mcp/index.js23--- a/dist/mcp/index.js
4+++ b/dist/mcp/index.js24+++ b/dist/mcp/index.js
5@@ -32,9 +32,9 @@ const KEEPALIVE_FRAME = ": keepalive\n\n";25@@ -28,13 +28,14 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
26 const KEEPALIVE_INTERVAL_MS = 25e3;
27 /** SSE comment frame the parser drops before any event dispatch. */
28 const KEEPALIVE_FRAME = ": keepalive\n\n";
29+const MAX_SSE_AGE_MS = 300_000;
30 /**
6 * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle31 * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle
7 * that the stream cleanup must invoke when the stream closes.32 * that the stream cleanup must invoke when the stream closes.
8 */33 */
@@ -14,7 +39,7 @@ index 1edcf0c8..e65cd346 100644 · 5 unmodified lines
14 }, KEEPALIVE_INTERVAL_MS);39 }, KEEPALIVE_INTERVAL_MS);
15 return handle;40 return handle;
16 }41 }
17@@ -180,10 +180,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {42@@ -180,10 +181,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
18 });43 });
19 return new Response(body, { status: 404 });44 return new Response(body, { status: 404 });
20 }45 }
@@ -34,7 +59,7 @@ index 1edcf0c8..e65cd346 100644 · 13 unmodified lines
34 request.headers.forEach((value, key) => {59 request.headers.forEach((value, key) => {
35 existingHeaders[key] = value;60 existingHeaders[key] = value;
36 });61 });
37@@ -206,45 +211,68 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {62@@ -206,45 +212,71 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
38 jsonrpc: "2.0"63 jsonrpc: "2.0"
39 });64 });
40 return new Response(body, { status: 500 });65 return new Response(body, { status: 500 });
@@ -45,17 +70,20 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines
45- return new Response(null, {70- return new Response(null, {
46+ }71+ }
47+ ws.accept();72+ ws.accept();
73+ const __closeSse = () => {
74+ __sseClosed = true;
75+ try {
76+ clearInterval(keepAlive);
77+ } catch {}
78+ try {
79+ ws.close(1013, "SSE client not draining");
80+ } catch {}
81+ writer.abort(new Error("SSE client not draining")).catch(() => {});
82+ };
48+ const __forwardSse = (frame) => {83+ const __forwardSse = (frame) => {
49+ if (__sseClosed) return __writeChain;84+ if (__sseClosed) return __writeChain;
50+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {85+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
51+ __sseClosed = true;86+ __closeSse();
52+ try {
53+ clearInterval(keepAlive);
54+ } catch {}
55+ try {
56+ ws.close(1013, "SSE client not draining");
57+ } catch {}
58+ writer.abort(new Error("SSE client not draining")).catch(() => {});
59+ return Promise.resolve();87+ return Promise.resolve();
60+ }88+ }
61+ __pendingBytes += frame.byteLength;89+ __pendingBytes += frame.byteLength;
@@ -129,7 +157,7 @@ index 1edcf0c8..e65cd346 100644 · 67 unmodified lines
129 }157 }
130 onClose().catch(console.error);158 onClose().catch(console.error);
131 });159 });
132@@ -279,10 +307,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {160@@ -279,10 +311,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
133 id: null,161 id: null,
134 jsonrpc: "2.0"162 jsonrpc: "2.0"
135 }), { status: 400 });163 }), { status: 400 });
@@ -140,6 +168,7 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines
140+ const { readable, writable } = new TransformStream();168+ const { readable, writable } = new TransformStream();
141+ const writer = writable.getWriter();169+ const writer = writable.getWriter();
142+ const encoder = new TextEncoder();170+ const encoder = new TextEncoder();
171+ const __openedAt = Date.now();
143+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;172+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
144+ let __pendingBytes = 0;173+ let __pendingBytes = 0;
145+ let __writeChain = Promise.resolve();174+ let __writeChain = Promise.resolve();
@@ -149,7 +178,7 @@ index 1edcf0c8..e65cd346 100644 · 3 unmodified lines
149 props: ctx.props,178 props: ctx.props,
150 jurisdiction: options.jurisdiction179 jurisdiction: options.jurisdiction
151 });180 });
152@@ -306,27 +339,51 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {181@@ -306,27 +344,86 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
153 if (!ws) {182 if (!ws) {
154 await writer.close();183 await writer.close();
155 return new Response("Failed to establish WS to DO", { status: 500 });184 return new Response("Failed to establish WS to DO", { status: 500 });
@@ -158,17 +187,52 @@ index 1edcf0c8..e65cd346 100644 · 2 unmodified lines
158- ws.addEventListener("message", (event) => {187- ws.addEventListener("message", (event) => {
159+ }188+ }
160+ ws.accept();189+ ws.accept();
190+ const __abortSse = () => {
191+ __sseClosed = true;
192+ try {
193+ clearInterval(keepAlive);
194+ } catch {}
195+ try {
196+ ws.close(1013, "SSE client not draining");
197+ } catch {}
198+ writer.abort(new Error("SSE client not draining")).catch(() => {});
199+ };
200+ const __closeSseGracefully = () => {
201+ __sseClosed = true;
202+ try {
203+ clearInterval(keepAlive);
204+ } catch {}
205+ try {
206+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");
207+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {
208+ writer.abort().catch(() => {});
209+ });
210+ } catch {
211+ __writeChain = writer.close().catch(() => {
212+ writer.abort().catch(() => {});
213+ });
214+ }
215+ try {
216+ ws.close(1000, "sse_max_age_rotation");
217+ } catch {}
218+ return __writeChain;
219+ };
161+ const __forwardSse = (frame) => {220+ const __forwardSse = (frame) => {
162+ if (__sseClosed) return __writeChain;221+ if (__sseClosed) return __writeChain;
222+ const ageMs = Date.now() - __openedAt;
223+ if (ageMs > MAX_SSE_AGE_MS) {
224+ console.log(JSON.stringify({
225+ event: "sse_max_age_close",
226+ sessionId,
227+ variant: "streamable-get",
228+ ageMs,
229+ pendingBytes: __pendingBytes
230+ }));
231+ __closeSseGracefully();
232+ return Promise.resolve();
233+ }
163+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {234+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
164+ __sseClosed = true;235+ __abortSse();
165+ try {
166+ clearInterval(keepAlive);
167+ } catch {}
168+ try {
169+ ws.close(1013, "SSE client not draining");
170+ } catch {}
171+ writer.abort(new Error("SSE client not draining")).catch(() => {});
172+ return Promise.resolve();236+ return Promise.resolve();
173+ }237+ }
174+ __pendingBytes += frame.byteLength;238+ __pendingBytes += frame.byteLength;
@@ -216,7 +280,7 @@ index 1edcf0c8..e65cd346 100644 · 41 unmodified lines
216 return new Response(readable, {280 return new Response(readable, {
217 headers: {281 headers: {
218 "Cache-Control": "no-cache",282 "Cache-Control": "no-cache",
219@@ -389,10 +446,15 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {283@@ -389,10 +486,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
220 const url = new URL(request.url);284 const url = new URL(request.url);
221 if (request.method === "GET" && basePattern.test(url)) {285 if (request.method === "GET" && basePattern.test(url)) {
222 const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();286 const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();
@@ -227,6 +291,7 @@ index 1edcf0c8..e65cd346 100644 · 4 unmodified lines
227+ const { readable, writable } = new TransformStream();291+ const { readable, writable } = new TransformStream();
228+ const writer = writable.getWriter();292+ const writer = writable.getWriter();
229+ const encoder = new TextEncoder();293+ const encoder = new TextEncoder();
294+ const __openedAt = Date.now();
230+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;295+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024;
231+ let __pendingBytes = 0;296+ let __pendingBytes = 0;
232+ let __writeChain = Promise.resolve();297+ let __writeChain = Promise.resolve();
@@ -236,7 +301,7 @@ index 1edcf0c8..e65cd346 100644 · 3 unmodified lines
236 endpointUrl.pathname = encodeURI(`${basePath}/message`);301 endpointUrl.pathname = encodeURI(`${basePath}/message`);
237 endpointUrl.searchParams.set("sessionId", sessionId);302 endpointUrl.searchParams.set("sessionId", sessionId);
238 const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;303 const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;
239@@ -414,35 +476,59 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {304@@ -414,35 +517,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
240 console.error("Failed to establish WebSocket connection");305 console.error("Failed to establish WebSocket connection");
241 await writer.close();306 await writer.close();
242 return new Response("Failed to establish WebSocket connection", { status: 500 });307 return new Response("Failed to establish WebSocket connection", { status: 500 });
@@ -245,17 +310,52 @@ index 1edcf0c8..e65cd346 100644 · 2 unmodified lines
245- ws.addEventListener("message", (event) => {310- ws.addEventListener("message", (event) => {
246+ }311+ }
247+ ws.accept();312+ ws.accept();
313+ const __abortSse = () => {
314+ __sseClosed = true;
315+ try {
316+ clearInterval(keepAlive);
317+ } catch {}
318+ try {
319+ ws.close(1013, "SSE client not draining");
320+ } catch {}
321+ writer.abort(new Error("SSE client not draining")).catch(() => {});
322+ };
323+ const __closeSseGracefully = () => {
324+ __sseClosed = true;
325+ try {
326+ clearInterval(keepAlive);
327+ } catch {}
328+ try {
329+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n");
330+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => {
331+ writer.abort().catch(() => {});
332+ });
333+ } catch {
334+ __writeChain = writer.close().catch(() => {
335+ writer.abort().catch(() => {});
336+ });
337+ }
338+ try {
339+ ws.close(1000, "sse_max_age_rotation");
340+ } catch {}
341+ return __writeChain;
342+ };
248+ const __forwardSse = (frame) => {343+ const __forwardSse = (frame) => {
249+ if (__sseClosed) return __writeChain;344+ if (__sseClosed) return __writeChain;
345+ const ageMs = Date.now() - __openedAt;
346+ if (ageMs > MAX_SSE_AGE_MS) {
347+ console.log(JSON.stringify({
348+ event: "sse_max_age_close",
349+ sessionId,
350+ variant: "legacy-sse",
351+ ageMs,
352+ pendingBytes: __pendingBytes
353+ }));
354+ __closeSseGracefully();
355+ return Promise.resolve();
356+ }
250+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {357+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) {
251+ __sseClosed = true;358+ __abortSse();
252+ try {
253+ clearInterval(keepAlive);
254+ } catch {}
255+ try {
256+ ws.close(1013, "SSE client not draining");
257+ } catch {}
258+ writer.abort(new Error("SSE client not draining")).catch(() => {});
259+ return Promise.resolve();359+ return Promise.resolve();
260+ }360+ }
261+ __pendingBytes += frame.byteLength;361+ __pendingBytes += frame.byteLength;
@@ -314,10 +414,11 @@ index 1edcf0c8..e65cd346 100644 · 52 unmodified lines
314 console.error("Error closing SSE connection:", error);414 console.error("Error closing SSE connection:", error);
315 }415 }
316 }416 }
317@@ -1700,4 +1786,4 @@ McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";417@@ -1698,6 +1860,6 @@ var McpAgent = class McpAgent extends Agent {
418 };
419 McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
318 //#endregion420 //#endregion
319 export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };421-export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
422+export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
320 423
321-//# sourceMappingURL=index.js.map424 //# sourceMappingURL=index.js.map
322\ No newline at end of file
323+//# sourceMappingURL=index.js.map
scripts/install.sh+1−1
@@ -1,7 +1,7 @@
1#!/usr/bin/env bash1#!/usr/bin/env bash
2set -euo pipefail2set -euo pipefail
3APP=executor3APP=executor
4REPO=RhysSullivan/executor4REPO=UsefulSoftwareCo/executor
5 5
6MUTED='\033[0;2m'6MUTED='\033[0;2m'
7RED='\033[0;31m'7RED='\033[0;31m'
scripts/pull-references.ts+1−1
@@ -8,7 +8,7 @@ const REFERENCE_DIR = join(import.meta.dirname, "../.reference"); · 7 unmodified lines
8const repos = [8const repos = [
9 { name: "effect", url: "https://github.com/Effect-TS/effect.git" },9 { name: "effect", url: "https://github.com/Effect-TS/effect.git" },
10 { name: "effect-atom", url: "https://github.com/tim-smart/effect-atom.git" },10 { name: "effect-atom", url: "https://github.com/tim-smart/effect-atom.git" },
11 { name: "executor", url: "https://github.com/RhysSullivan/executor.git" },11 { name: "executor", url: "https://github.com/UsefulSoftwareCo/executor.git" },
12 {12 {
13 name: "tanstack-router",13 name: "tanstack-router",
14 url: "https://github.com/TanStack/router.git",14 url: "https://github.com/TanStack/router.git",